Skip to main content

tower_rules/
lib.rs

1//! `TowerRule` schema — predicate + trigger + federation types for yah-tower.
2//!
3//! This crate is the schema source of truth for tower rules. It has zero
4//! dependencies on the tower runtime; the tower engine, the desktop authoring
5//! UI, `tower.simulate`, and tests all depend on this crate directly.
6//!
7//! Architecture: `.yah/docs/architecture/A052-yah-tower.md`
8//!
9//! @yah:ticket(R380-T4, "Add tower-rules TaskPlacement (on-wire flat form, no MeshIdent)")
10//! @yah:assignee(agent:claude)
11//! @yah:at(2026-06-01T21:06:13Z)
12//! @yah:status(review)
13//! @yah:parent(R380)
14//! @yah:next("The tower-rules enum at lib.rs:257 today is Local | Remote | Integration with no MeshIdent (cross-crate dep direction forbids it). Replace with TaskPlacement { location: LocationKind (Local|RemoteAny{tier}), runtime: Native|Container } — note no Remote(MeshIdent) variant here either.")
15//! @yah:next("Provide a converter from tower-rules TaskPlacement → task crate TaskPlacement that the consumer applies at the boundary (tower-rules can't depend on task crate types).")
16//! @yah:next("Update ts-rs export — new TS file TaskPlacement.ts replaces ForgeWhere.ts. Coordinate with frontend integration tests.")
17//! @yah:handoff("Added TaskPlacement / TaskLocation / TaskRuntime / TierTag in crates/yah/tower-rules/src/lib.rs alongside the existing ForgeWhere (kept for T5 to migrate Trigger::AgentDispatch). Wire format chosen to match task crate: TaskLocation uses tag=\"kind\" with snake_case discriminator so boundary converters can pass JSON through for shared variants. Tower-rules TaskLocation has only Local | RemoteAny{tier} — no Remote(MeshIdent) variant since rules can't name specific yubaba nodes from YAML. Added 5 round-trip + wire-format tests; all 24 tower-rules tests pass. ForgeWhere is now marked deprecated in its rustdoc + the generated ForgeWhere.ts.")
18//! @yah:next("R380-T5 picks up: replace Trigger::AgentDispatch.forge_where: ForgeWhere with placement: TaskPlacement, update parse.rs YAML loader, update tower/src/triggers.rs ForgeSpec, update tower/src/rules/validate.rs round-trip fixtures, update tests in tower/tests/{dispatch,simulate,triggers}.rs and tower-rules/tests/round_trip.rs, update tower-rules library YAML files (yubaba-restart-on-error.yaml etc.)")
19//! @yah:next("Boundary converter: tower → task ForgeSpec construction needs to widen tower_rules::TaskPlacement → task::TaskPlacement. Mapping is mechanical: Local→Local, RemoteAny{tier:tower_rules::TierTag(s)}→RemoteAny{tier:workload_spec::TierTag(s)}, runtime variants pass through. Lives in whichever crate already depends on both (probably the tower/cli boundary in app/yah/cli/src/tower.rs or a tower-task adapter). The tower crate itself currently does NOT depend on task — pulling task in there is the natural seam.")
20//! @yah:verify("cargo test -p tower-rules")
21//! @yah:verify("cargo run -q -p tower-rules --bin tower-rules-export-ts && ls packages/yah/ui/src/gen/{TaskPlacement,TaskLocation,TaskRuntime,TierTag}.ts")
22//!
23//! @yah:ticket(R406-T7, "Structured drain protocol: versioned wire format, ack semantics, SIGTERM fallback")
24//! @yah:assignee(agent:claude)
25//! @yah:at(2026-06-02T03:26:44Z)
26//! @yah:status(review)
27//! @yah:phase(P2)
28//! @yah:parent(R406)
29//! @arch:see(.yah/docs/working/W154-yubaba-dual-runtime.md)
30//! @yah:depends_on(R406-T6)
31//! @yah:handoff("T7 ships the structured drain protocol end-to-end at the protocol+enforcer layer. (1) kamaji-proto: DrainOutcome { Flushed | Checkpointed | ForceKilled | UnknownWorkload | Unsupported } and a peer DrainCompleted push message (non-exhaustive, V1-compatible). DrainAck rustdoc now documents the two-mode semantics (sync T7 default / async T8 push). DrainBudget::total_ms() helper. (2) app/yah/kamaji/src/drain.rs: enforce_drain state machine — SIGTERM via pidfd_send_signal (race-free), AsyncFd::readable for exit observation bounded by flush_ms+checkpoint_ms, SIGKILL escalation + 5s safety reap. classify_clean_exit pure helper splits Flushed vs Checkpointed by elapsed-vs-flush_ms boundary (≤ inclusive on flush). (3) Server registry gained insert_drainable / take_drainable + DrainableHandle { pid, pidfd }. Drain RPC short-circuits on unknown workload, otherwise runs enforce_drain synchronously and translates DrainOutcome → DrainAck { accepted, reason } via drain_outcome_to_ack (decision-table: clean phase = accepted=true with elapsed_ms; ForceKilled / Unsupported / Unknown / DrainError = accepted=false). (4) Coverage: 19 kamaji-proto codec tests (8 new for drain outcomes + DrainCompleted round-trip + DrainBudget saturation), 47 kamaji unit tests (drain::tests pure boundary cases + server::tests decision-table-as-spec on every DrainOutcome variant + drain_unknown_workload UDS round-trip), 1 integration test extended in tests/uds_skeleton.rs (Drain over UDS for unknown id). Linux-only drain::linux::tests exercise the real-child path (SIGTERM-responsive vs SIGTERM-ignoring) but only run on Linux CI — not exercised on darwin local.")
32//! @yah:next("T8 picks up: extract Yubaba-proper supervision out of in-process, dispatch through Kamaji over UDS. Kamaji's Deploy handler currently still returns Internal — T8 wires it to native::spawn and populates registry.insert_drainable(id, { pid, pidfd }) so the Drain handler has a real handle to consume.")
33//! @yah:next("T8 also adds the back-channel (Kamaji → Yubaba push) that lets Kamaji reply DrainAck { accepted=true, reason='started' } immediately and push DrainCompleted { request_id, id, outcome } when the drain finishes — that switches the wire shape from synchronous-mode to async-mode without touching V1 protocol-version (DrainCompleted rides #[non_exhaustive]).")
34//! @yah:next("T11 picks up the workload-side leg: pick stdio-sentinel vs HTTP-endpoint, define the workload's ack envelope, then drain.rs grows a phase boundary signal so workloads know they crossed flush_ms into checkpoint_ms without consulting the clock. The pidfd-level SIGTERM floor stays as the fallback.")
35//! @yah:verify("cargo test -p kamaji-proto -p kamaji")
36
37use serde::{Deserialize, Serialize};
38use ts_rs::TS;
39
40mod version;
41pub use version::SchemaVersion;
42
43// ── Primitive newtypes ────────────────────────────────────────────────────────
44
45/// Unique identifier for a rule instance, e.g. `"yubaba-quorum-loss"`.
46#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
47#[ts(export)]
48pub struct RuleId(pub String);
49
50/// DNS-segment identity for a service on the cluster mesh, e.g.
51/// `"noisetable-api.pdx"`.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
53#[ts(export)]
54pub struct MeshIdent(pub String);
55
56/// Opaque identifier for a forge run.
57#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
58#[ts(export)]
59pub struct ForgeId(pub String);
60
61/// Reference to an agent class definition, e.g. `"gnome/fixer"`.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
63#[ts(export)]
64pub struct AgentClassRef(pub String);
65
66/// Mustache-style template for the agent prompt, rendered with the captured
67/// event payload at dispatch time.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
69#[ts(export)]
70pub struct PromptTemplate(pub String);
71
72// ── Scope filter ──────────────────────────────────────────────────────────────
73
74/// Filter over the scope of a scryer event.
75///
76/// Determines which event scopes a predicate can match against. `Any` matches
77/// all scopes; the variant forms narrow to a specific scope kind and optionally
78/// a specific identity within that kind.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
80#[serde(tag = "type", rename_all = "snake_case")]
81#[ts(export)]
82pub enum ScopeFilter {
83    /// Match events from any scope.
84    Any,
85    /// Match `TaskRun` scope events; `None` matches any task run id.
86    TaskRun { id: Option<String> },
87    /// Match `Service` scope events; `None` matches any mesh identity.
88    Service { ident: Option<MeshIdent> },
89    /// Match `Forge` scope events; `None` matches any forge run.
90    Forge { id: Option<ForgeId> },
91    /// Fan out to all peers in the yubaba mesh. Requires `FederationPolicy`
92    /// to be `MeshWide` or `MirrorSet`; local-only rules ignore this variant.
93    Federated,
94}
95
96// ── Level filter ──────────────────────────────────────────────────────────────
97
98/// Minimum log level for a predicate to match. Ordered: Debug < Info < Warn < Error.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)]
100#[serde(rename_all = "snake_case")]
101#[ts(export)]
102pub enum LevelFilter {
103    Debug,
104    Info,
105    Warn,
106    Error,
107}
108
109// ── String filter ─────────────────────────────────────────────────────────────
110
111/// Pattern match over a string field.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
113#[serde(tag = "type", rename_all = "snake_case")]
114#[ts(export)]
115pub enum StringFilter {
116    /// Glob pattern, e.g. `"database.*"`.
117    Glob { pattern: String },
118    /// Regular expression.
119    Regex { pattern: String },
120    /// Exact byte-for-byte equality.
121    Exact { value: String },
122}
123
124// ── Field filter ──────────────────────────────────────────────────────────────
125
126/// Operation applied to a field value.
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
128#[serde(tag = "op", rename_all = "snake_case")]
129#[ts(export)]
130pub enum FieldOp {
131    /// Field equals the given JSON value.
132    Eq {
133        #[ts(type = "unknown")]
134        value: serde_json::Value,
135    },
136    /// Field string contains the given substring.
137    Contains { substring: String },
138    /// Field string matches the given pattern.
139    Matches { filter: StringFilter },
140    /// Field exists in the event payload (any non-null value).
141    Exists,
142}
143
144/// Jsonpath-flavored filter applied to an event's `fields` object.
145///
146/// `path` is a dot-separated field path, e.g. `"error.code"`. Nested paths
147/// traverse JSON objects; array indexing is not supported in tower-1.
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
149#[ts(export)]
150pub struct FieldFilter {
151    /// Dot-separated path into the event `fields` payload.
152    pub path: String,
153    /// Operation to apply at the resolved path.
154    pub op: FieldOp,
155}
156
157// ── Rate predicate ────────────────────────────────────────────────────────────
158
159/// "At least N matches in T milliseconds" sliding-window rate filter.
160///
161/// When present on an `EventMatch` predicate, the predicate only fires once
162/// the rate threshold is met. Used to suppress noisy one-off events and only
163/// react to sustained error bursts.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
165#[ts(export)]
166pub struct RatePredicate {
167    /// Minimum number of matching events required in the window.
168    pub min_count: u32,
169    /// Sliding window length in milliseconds.
170    pub window_ms: u64,
171}
172
173// ── Scryer health signal ──────────────────────────────────────────────────────
174
175/// Scryer substrate health condition that tower can predicate over.
176///
177/// These signals come from scryer's own self-monitoring emissions rather than
178/// from service event streams. Allows tower to react to observability
179/// infrastructure failures as well as application-level events.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
181#[serde(rename_all = "snake_case")]
182#[ts(export)]
183pub enum HealthSignal {
184    /// Scryer ingestion is falling behind the event producer rate.
185    IngestionLag,
186    /// The in-memory ring buffer has overflowed and events are being dropped.
187    RingOverflow,
188    /// A federated query to a peer scryer timed out.
189    FederationTimeout,
190    /// Yubaba's raft consensus has lost quorum (fewer than ⌊N/2⌋+1 peers
191    /// reachable). The first canonical tower rule targets this signal.
192    YubabaRaftQuorumLost,
193}
194
195// ── Compound operator ─────────────────────────────────────────────────────────
196
197/// Boolean operator for compound predicates.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
199#[serde(rename_all = "snake_case")]
200#[ts(export)]
201pub enum CompoundOp {
202    /// All children must match.
203    And,
204    /// At least one child must match.
205    Or,
206    /// The single child must NOT match. Tower-1 validates that `Not` has
207    /// exactly one child at compile time.
208    Not,
209}
210
211// ── Predicate ─────────────────────────────────────────────────────────────────
212
213/// Structured filter over scryer events.
214///
215/// The rule engine compiles a `Predicate` into a scryer subscription filter
216/// (R093-F1+F2 query surface). Predicates push down to where events are
217/// produced; expensive field-level filtering runs after the cheaper
218/// level/target indexes are consulted.
219#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
220#[serde(tag = "type", rename_all = "snake_case")]
221#[ts(export)]
222pub enum Predicate {
223    /// Match against structured event fields.
224    EventMatch {
225        /// Which scope kinds and identities to watch.
226        scope: ScopeFilter,
227        /// Minimum log level. `None` matches any level.
228        #[serde(skip_serializing_if = "Option::is_none")]
229        level: Option<LevelFilter>,
230        /// Filter on the event `target` field. `None` matches any target.
231        #[serde(skip_serializing_if = "Option::is_none")]
232        target: Option<StringFilter>,
233        /// Zero or more field-level filters, all of which must match (AND).
234        #[serde(default, skip_serializing_if = "Vec::is_empty")]
235        fields: Vec<FieldFilter>,
236        /// Rate gate: only fire once at least `min_count` matches accumulate
237        /// in `window_ms`. `None` fires on every individual match.
238        #[serde(skip_serializing_if = "Option::is_none")]
239        rate: Option<RatePredicate>,
240    },
241    /// Predicate over a scryer substrate health signal rather than an
242    /// application event stream. Opens a parallel subscription on scryer's
243    /// own health emissions.
244    ScryerHealth {
245        signal: HealthSignal,
246    },
247    /// Boolean combination of child predicates.
248    Compound {
249        op: CompoundOp,
250        children: Vec<Predicate>,
251    },
252}
253
254// ── Federation policy ─────────────────────────────────────────────────────────
255
256/// Controls which machines' scryer instances this rule subscribes to.
257///
258/// Tower opens scryer subscriptions once per peer per rule (not once per event
259/// per peer), keeping federation traffic bounded per the arch doc
260/// §Subscriptions efficiency story.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
262#[serde(tag = "type", rename_all = "snake_case")]
263#[ts(export)]
264pub enum FederationPolicy {
265    /// Subscribe only to the local machine's scryer. Default.
266    LocalOnly,
267    /// Subscribe to scryer on every peer in the yubaba mesh.
268    MeshWide,
269    /// Subscribe to scryer on a named subset of mirrors.
270    MirrorSet { mirrors: Vec<String> },
271}
272
273impl Default for FederationPolicy {
274    fn default() -> Self {
275        Self::LocalOnly
276    }
277}
278
279// ── Tier tag ──────────────────────────────────────────────────────────────────
280
281/// Capacity-tier selector for a yubaba node, e.g. `"infra"` or `"tenant"`.
282///
283/// Local mirror of `workload_spec::TierTag` — tower-rules can't depend on
284/// workload-spec for layering reasons (the rule schema sits below the workload
285/// types in the dep graph). Boundary consumers map this to the workload-spec
286/// variant when bridging into the task crate.
287#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
288#[ts(export)]
289pub struct TierTag(pub String);
290
291// ── Task placement ────────────────────────────────────────────────────────────
292
293/// How a task is sandboxed: subprocess vs. image-backed container.
294///
295/// Orthogonal to [`TaskLocation`]; combine them inside [`TaskPlacement`].
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
297#[serde(rename_all = "snake_case")]
298#[ts(export)]
299pub enum TaskRuntime {
300    /// Subprocess on the host (no container).
301    Native,
302    /// Image-backed container (docker/podman locally; containerd on yubaba).
303    Container,
304}
305
306/// Where the task runs.  Wire-form view, expressible from a tower rule.
307///
308/// Thinner than `task::TaskLocation` by design: tower-rules can't reference
309/// the rich `Remote { node: MeshIdent }` variant because the task crate
310/// (which owns the canonical placement type) sits above tower-rules in the
311/// dep graph, and a rule authored as YAML has no natural way to name a
312/// specific yubaba node by mesh identity anyway.  Rules that want to land on
313/// a specific node must use [`TaskLocation::RemoteAny`] with a tier filter
314/// and let yubaba admission control pick.
315///
316/// Boundary consumers (the tower → task bridge) widen this into
317/// `task::TaskLocation`: `Local` maps straight across; `RemoteAny { tier }`
318/// maps to `task::TaskLocation::RemoteAny { tier }` after lifting the local
319/// `TierTag` into `workload_spec::TierTag`.
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
321#[serde(tag = "kind", rename_all = "snake_case")]
322#[ts(export)]
323pub enum TaskLocation {
324    /// Run on the dev box that submitted the rule.
325    Local,
326    /// Run on any yubaba node in the requested tier; yubaba picks based on
327    /// capacity admission control (R090-F3).
328    RemoteAny { tier: TierTag },
329}
330
331/// Orthogonal placement of a task: *where* it runs (`location`) × *how* it's
332/// sandboxed (`runtime`).
333///
334/// See `.yah/docs/working/W149-task-placement-axis.md` for the model and the
335/// four quadrants.
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
337#[ts(export)]
338pub struct TaskPlacement {
339    pub location: TaskLocation,
340    pub runtime: TaskRuntime,
341}
342
343impl TaskPlacement {
344    pub fn new(location: TaskLocation, runtime: TaskRuntime) -> Self {
345        Self { location, runtime }
346    }
347}
348
349// ── Notification channel ──────────────────────────────────────────────────────
350
351/// Channel through which a `Notification` trigger delivers attention.
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
353#[serde(tag = "type", rename_all = "snake_case")]
354#[ts(export)]
355pub enum NotificationChannel {
356    /// Tauri desktop badge + system notification.
357    DesktopBadge,
358    /// Mobile push notification (APNs / FCM — planned; not in tower-1).
359    Push,
360    /// HTTP POST to operator-configured URL with the event payload as JSON body.
361    Webhook { url: String },
362}
363
364// ── Severity ──────────────────────────────────────────────────────────────────
365
366/// Severity of a `Notification` trigger.
367#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)]
368#[serde(rename_all = "snake_case")]
369#[ts(export)]
370pub enum Severity {
371    Info,
372    Warning,
373    Critical,
374}
375
376// ── Yubaba action kind ────────────────────────────────────────────────────────
377
378/// Bounded, reversible cluster operation dispatched by a `YubabaAction` trigger.
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
380#[serde(tag = "type", rename_all = "snake_case")]
381#[ts(export)]
382pub enum YubabaActionKind {
383    /// Restart the named workload.
384    RestartWorkload,
385    /// Drain the named workload (stop accepting new connections; let in-flight
386    /// requests complete before stopping).
387    Drain,
388    /// Scale the named workload to `replicas` replicas.
389    Scale { replicas: u32 },
390}
391
392// ── Trigger ───────────────────────────────────────────────────────────────────
393
394/// What tower does when a rule's predicate matches.
395///
396/// Three families, one per operator intent:
397/// - **Agent dispatch** — unbounded reasoning: synthesise a `ForgeSpec` and
398///   hand it to `forge.run`. The agent reads from scryer, makes changes, exits.
399/// - **Notification** — just attention: surface in the desktop, push to mobile,
400///   fire a webhook.
401/// - **Yubaba action** — bounded and reversible: restart, drain, scale.
402#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
403#[serde(tag = "type", rename_all = "snake_case")]
404#[ts(export)]
405pub enum Trigger {
406    /// Synthesise a `ForgeSpec` and dispatch an agent run.
407    AgentDispatch {
408        /// Which agent class to instantiate.
409        agent_class: AgentClassRef,
410        /// Template rendered with the captured event payload to produce the
411        /// agent's initial prompt. Treat the payload as data with delimiters
412        /// to prevent prompt injection from hostile log lines.
413        prompt_template: PromptTemplate,
414        /// Where the agent runs (location × runtime).  See W149 for the model.
415        placement: TaskPlacement,
416    },
417    /// Push attention to operator channels without executing any automation.
418    Notification {
419        /// One or more channels to notify.
420        channels: Vec<NotificationChannel>,
421        /// Severity surfaced in desktop badge and push notifications.
422        severity: Severity,
423    },
424    /// Call a yubaba RPC to take a bounded cluster action.
425    YubabaAction {
426        kind: YubabaActionKind,
427        /// Which mesh identity to act on.
428        target: MeshIdent,
429    },
430}
431
432// ── TowerRule ─────────────────────────────────────────────────────────────────
433
434/// A complete tower rule: a structured predicate over scryer events paired
435/// with a trigger that fires when the predicate matches.
436///
437/// Authored as YAML in `.yah/cloud/rules/<name>.yaml` and loaded by the tower
438/// daemon at boot. Edits are hot-reloaded via the config-file watcher (F6).
439#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
440#[ts(export)]
441pub struct TowerRule {
442    /// Wire-format version; defaults to V1 for backward compat with rule files
443    /// authored before schema versioning was introduced.
444    #[serde(default)]
445    pub schema_version: SchemaVersion,
446    /// Stable rule identifier. Used for dedup, audit trail keys, and CLI
447    /// references. Should be a kebab-case slug, e.g. `"yubaba-quorum-loss"`.
448    pub id: RuleId,
449    /// Human-readable rule name shown in the desktop UI and CLI.
450    pub name: String,
451    /// Structured predicate compiled into a scryer subscription filter.
452    pub predicate: Predicate,
453    /// What tower does when the predicate matches.
454    pub trigger: Trigger,
455    /// Suppress re-firing within this window (milliseconds) after a successful
456    /// dispatch. `None` fires on every individual match with no rate limiting.
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub debounce_ms: Option<u64>,
459    /// Which machines' scryer instances this rule subscribes to.
460    #[serde(default)]
461    pub federation: FederationPolicy,
462    /// Whether this rule is active. Disabled rules are loaded but not
463    /// subscribed — useful for authoring and dry-run (tower.simulate).
464    pub enabled: bool,
465}
466
467// ── Export helper ─────────────────────────────────────────────────────────────
468
469/// Export all tower-rules TypeScript bindings to `out_dir`.
470///
471/// Called by `cargo run -p tower-rules --bin tower-rules-export-ts`.
472pub fn export_ts(out_dir: &str) -> Result<(), ts_rs::ExportError> {
473    TowerRule::export_all_to(out_dir)
474}