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}