返回 CodeWhale
validate.rs
根目录 / crates / tui / src / work_graph / validate.rs
1 //! Invariant validation — fail closed.
2 //!
3 //! [`validate`] checks every whole-snapshot invariant (V1–V8 below, plus
4 //! structural well-formedness). The reducer calls it on the candidate
5 //! snapshot after every change and rejects the change on any violation,
6 //! leaving the input snapshot untouched. There is no fail-open path: if a
7 //! node cannot be verified, it does not become Verified — verification
8 //! infrastructure trouble must surface as a rejection (callers then mark the
9 //! node Blocked), never as silently-assumed success.
10 //!
11 //! Invariants:
12 //! - V1 `DependsOn` edges are acyclic.
13 //! - V2 every live (`Initializing`/`Active`/`Waiting`) Operation reaches an
14 //! Objective/PlanStep via `Contains` ancestry — no orphaned live work.
15 //! - V3 `binding.is_some()` ⇒ `kind == Operation`.
16 //! - V4 `Verified` ⇒ acceptance non-empty ⇒ a `Verifies`-edge evidence path
17 //! satisfies every requirement. Completion is never verification.
18 //! - V5 `Blocked` ⇒ an incoming `Blocks` edge, an unmet `DependsOn`, or a
19 //! pending `RequiresApproval` path exists.
20 //! - V6 each binding's `external` matches exactly one identity scheme and no
21 //! two operations bind the same external identity.
22 //! - V7 `RuntimeRef`/`LaneRef` nodes never carry liveness state — the
23 //! owning subsystems are the only liveness source.
24 //! - V8 history is bounded and its revisions strictly increase.
25 //! - V9 terminal states are never overwritten except via explicit
26 //! `Supersede` (enforced in the reducer, which sees the predecessor
27 //! snapshot; single-snapshot validation cannot observe overwrites).
28 //! - V10 compat projections are pure functions of the snapshot — enforced at
29 //! the type level: projection functions take `&WorkGraphSnapshot` (see
30 //! `compat.rs`); nothing hands them mutable graph access.
31
32 use std::collections::{HashMap, HashSet};
33
34 use serde::{Deserialize, Serialize};
35
36 use super::ids::WorkNodeId;
37 use super::model::{
38 ACTIVITY_CAP, EdgeKind, HISTORY_CAP, NodeKind, NodeState, SCHEMA_VERSION, WorkActivityEvent,
39 WorkGraphSnapshot, WorkNode, external_identity_is_well_formed,
40 };
41
42 /// Which rule a violation belongs to.
43 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44 #[serde(rename_all = "snake_case")]
45 pub enum ValidationCode {
46 /// Basic well-formedness (unique IDs, resolvable endpoints, schema).
47 Structural,
48 V1,
49 V2,
50 V3,
51 V4,
52 V5,
53 V6,
54 V7,
55 V8,
56 V9,
57 /// Never emitted at runtime: enforced by projection function signatures.
58 V10,
59 }
60
61 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62 pub struct Violation {
63 pub code: ValidationCode,
64 pub message: String,
65 }
66
67 /// Result of a failed validation. A change producing any violation is
68 /// rejected wholesale; the pre-change snapshot is returned to the caller
69 /// untouched.
70 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71 pub struct ValidationReport {
72 pub violations: Vec<Violation>,
73 }
74
75 impl ValidationReport {
76 #[must_use]
77 pub fn single(code: ValidationCode, message: impl Into<String>) -> Self {
78 ValidationReport {
79 violations: vec![Violation {
80 code,
81 message: message.into(),
82 }],
83 }
84 }
85
86 #[must_use]
87 pub fn contains_code(&self, code: ValidationCode) -> bool {
88 self.violations.iter().any(|v| v.code == code)
89 }
90 }
91
92 impl std::fmt::Display for ValidationReport {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 write!(f, "work graph validation failed:")?;
95 for v in &self.violations {
96 write!(f, " [{:?}] {};", v.code, v.message)?;
97 }
98 Ok(())
99 }
100 }
101
102 impl std::error::Error for ValidationReport {}
103
104 /// Validate a whole snapshot. `Ok(())` or every violation found.
105 pub fn validate(snapshot: &WorkGraphSnapshot) -> Result<(), ValidationReport> {
106 let mut violations = Vec::new();
107
108 check_structural(snapshot, &mut violations);
109 check_v1_depends_on_acyclic(snapshot, &mut violations);
110 check_v2_live_operations_rooted(snapshot, &mut violations);
111 check_v3_binding_only_on_operations(snapshot, &mut violations);
112 check_v4_verified_requires_evidence(snapshot, &mut violations);
113 check_v5_blocked_has_cause(snapshot, &mut violations);
114 check_v6_binding_identity(snapshot, &mut violations);
115 check_v7_refs_inert(snapshot, &mut violations);
116 check_v8_history_bounded_monotonic(snapshot, &mut violations);
117
118 if violations.is_empty() {
119 Ok(())
120 } else {
121 Err(ValidationReport { violations })
122 }
123 }
124
125 fn check_structural(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
126 if snapshot.schema != SCHEMA_VERSION {
127 out.push(Violation {
128 code: ValidationCode::Structural,
129 message: format!("unknown schema {}", snapshot.schema),
130 });
131 }
132 let mut node_ids = HashSet::new();
133 for node in &snapshot.nodes {
134 if !node_ids.insert(&node.id) {
135 out.push(Violation {
136 code: ValidationCode::Structural,
137 message: format!("duplicate node id {}", node.id),
138 });
139 }
140 if node.evidence.is_some() && !matches!(node.kind, NodeKind::Evidence) {
141 out.push(Violation {
142 code: ValidationCode::Structural,
143 message: format!(
144 "node {} carries evidence but is not an Evidence node",
145 node.id
146 ),
147 });
148 }
149 }
150 let mut edge_ids = HashSet::new();
151 for edge in &snapshot.edges {
152 if !edge_ids.insert(&edge.id) {
153 out.push(Violation {
154 code: ValidationCode::Structural,
155 message: format!("duplicate edge id {}", edge.id),
156 });
157 }
158 for endpoint in [&edge.from, &edge.to] {
159 if !node_ids.contains(endpoint) {
160 out.push(Violation {
161 code: ValidationCode::Structural,
162 message: format!("edge {} references missing node {}", edge.id, endpoint),
163 });
164 }
165 }
166 }
167
168 let mut plan_ids = HashSet::new();
169 for id in &snapshot.compat.plan_order {
170 if !plan_ids.insert(id) {
171 out.push(Violation {
172 code: ValidationCode::Structural,
173 message: format!("duplicate plan projection node {id}"),
174 });
175 }
176 match snapshot.node(id) {
177 Some(node) if matches!(node.kind, NodeKind::PlanStep) => {}
178 Some(_) => out.push(Violation {
179 code: ValidationCode::Structural,
180 message: format!("plan projection node {id} is not a PlanStep"),
181 }),
182 None => out.push(Violation {
183 code: ValidationCode::Structural,
184 message: format!("plan projection references missing node {id}"),
185 }),
186 }
187 }
188
189 let mut todo_ids = HashSet::new();
190 let mut active_todos = 0usize;
191 for binding in &snapshot.compat.todos {
192 if binding.legacy_id == 0 || !todo_ids.insert(binding.legacy_id) {
193 out.push(Violation {
194 code: ValidationCode::Structural,
195 message: format!("invalid or duplicate legacy To-do id {}", binding.legacy_id),
196 });
197 }
198 match snapshot.node(&binding.node) {
199 Some(node) => {
200 if node.kind != NodeKind::PlanStep {
201 out.push(Violation {
202 code: ValidationCode::Structural,
203 message: format!(
204 "To-do projection {} node {} is not a PlanStep",
205 binding.legacy_id, binding.node
206 ),
207 });
208 }
209 if matches!(node.state, NodeState::Active) {
210 active_todos += 1;
211 }
212 }
213 None => out.push(Violation {
214 code: ValidationCode::Structural,
215 message: format!(
216 "To-do projection {} references missing node {}",
217 binding.legacy_id, binding.node
218 ),
219 }),
220 }
221 if let Some(index) = binding.plan_index {
222 let aliased = usize::try_from(index)
223 .ok()
224 .and_then(|index| snapshot.compat.plan_order.get(index));
225 if aliased != Some(&binding.node) {
226 out.push(Violation {
227 code: ValidationCode::Structural,
228 message: format!(
229 "To-do projection {} has an invalid plan alias",
230 binding.legacy_id
231 ),
232 });
233 }
234 }
235 }
236 if active_todos > 1 {
237 out.push(Violation {
238 code: ValidationCode::Structural,
239 message: "legacy To-do projection has more than one active row".to_string(),
240 });
241 }
242
243 if snapshot.activities.len() > ACTIVITY_CAP {
244 out.push(Violation {
245 code: ValidationCode::Structural,
246 message: format!(
247 "activity length {} exceeds bound {ACTIVITY_CAP}",
248 snapshot.activities.len()
249 ),
250 });
251 }
252 for activity in snapshot.activities.iter() {
253 let (requested, effective, provider_kind, provider, endpoint_identity, model, operation) =
254 match activity {
255 WorkActivityEvent::ReasoningEffortChanged {
256 requested,
257 effective,
258 provider_kind,
259 provider,
260 endpoint_identity,
261 model,
262 operation,
263 ..
264 } => (
265 requested,
266 effective,
267 provider_kind,
268 provider,
269 endpoint_identity,
270 model,
271 operation,
272 ),
273 };
274 if matches!(
275 requested,
276 super::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
277 | super::ReasoningEffortTier::Unavailable
278 ) {
279 out.push(Violation {
280 code: ValidationCode::Structural,
281 message: "requested reasoning effort is not an operator-selectable tier"
282 .to_string(),
283 });
284 }
285 if provider.is_empty()
286 || provider.chars().count() > 128
287 || provider
288 .chars()
289 .any(|ch| ch.is_whitespace() || ch.is_control())
290 {
291 out.push(Violation {
292 code: ValidationCode::Structural,
293 message: "activity provider is not a bounded route identity".to_string(),
294 });
295 }
296 let provenance_is_bounded = provider_kind.is_some()
297 && endpoint_identity.as_ref().is_some_and(|endpoint| {
298 !endpoint.is_empty()
299 && endpoint.chars().count() <= 512
300 && !endpoint.chars().any(char::is_control)
301 })
302 && model.as_ref().is_some_and(|model| {
303 !model.trim().is_empty()
304 && model.chars().count() <= 256
305 && !model.chars().any(char::is_control)
306 });
307 if !provenance_is_bounded {
308 if *effective != super::ReasoningEffortTier::Unavailable {
309 out.push(Violation {
310 code: ValidationCode::Structural,
311 message:
312 "activity without bounded route provenance must be effective unavailable"
313 .to_string(),
314 });
315 }
316 } else {
317 let api_provider = provider_kind.expect("provenance bounded above");
318 if api_provider != crate::config::ProviderKind::Custom
319 && provider != api_provider.as_str()
320 {
321 out.push(Violation {
322 code: ValidationCode::Structural,
323 message: "activity provider identity does not match its recorded kind"
324 .to_string(),
325 });
326 continue;
327 }
328 let constrained = match api_provider {
329 crate::config::ProviderKind::Custom => {
330 Some(super::ReasoningEffortTier::Unavailable)
331 }
332 api_provider => super::model::constrained_effective_reasoning_for_route(
333 *requested,
334 api_provider,
335 endpoint_identity.as_deref().expect("bounded above"),
336 model.as_deref().expect("bounded above"),
337 ),
338 };
339 if constrained.is_some_and(|expected| *effective != expected) {
340 out.push(Violation {
341 code: ValidationCode::Structural,
342 message: "activity effective reasoning is impossible for its recorded route"
343 .to_string(),
344 });
345 } else if constrained.is_none()
346 && matches!(
347 effective,
348 super::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
349 )
350 {
351 out.push(Violation {
352 code: ValidationCode::Structural,
353 message: "granularity-unavailable receipt is not valid for this recorded route"
354 .to_string(),
355 });
356 }
357 }
358 if let Some(operation) = operation {
359 match snapshot.node(operation) {
360 Some(node) if node.kind == NodeKind::Operation => {}
361 Some(_) => out.push(Violation {
362 code: ValidationCode::Structural,
363 message: format!("activity operation {operation} is not an Operation node"),
364 }),
365 None => out.push(Violation {
366 code: ValidationCode::Structural,
367 message: format!("activity references missing operation {operation}"),
368 }),
369 }
370 }
371 }
372 }
373
374 /// V1: DFS three-color cycle detection over `DependsOn` edges.
375 fn check_v1_depends_on_acyclic(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
376 let mut adjacency: HashMap<&WorkNodeId, Vec<&WorkNodeId>> = HashMap::new();
377 for edge in &snapshot.edges {
378 if matches!(edge.kind, EdgeKind::DependsOn) {
379 adjacency.entry(&edge.from).or_default().push(&edge.to);
380 }
381 }
382 let mut done: HashSet<&WorkNodeId> = HashSet::new();
383 let mut in_progress: HashSet<&WorkNodeId> = HashSet::new();
384
385 fn visit<'a>(
386 node: &'a WorkNodeId,
387 adjacency: &HashMap<&'a WorkNodeId, Vec<&'a WorkNodeId>>,
388 done: &mut HashSet<&'a WorkNodeId>,
389 in_progress: &mut HashSet<&'a WorkNodeId>,
390 ) -> bool {
391 if done.contains(node) {
392 return true;
393 }
394 if !in_progress.insert(node) {
395 return false; // back edge → cycle
396 }
397 let acyclic = adjacency
398 .get(node)
399 .map(|next| next.iter().all(|n| visit(n, adjacency, done, in_progress)))
400 .unwrap_or(true);
401 in_progress.remove(node);
402 done.insert(node);
403 acyclic
404 }
405
406 for node in &snapshot.nodes {
407 if !visit(&node.id, &adjacency, &mut done, &mut in_progress) {
408 out.push(Violation {
409 code: ValidationCode::V1,
410 message: format!("depends_on cycle reachable from node {}", node.id),
411 });
412 return; // one report is enough; graph is already invalid
413 }
414 }
415 }
416
417 /// V2: every live Operation climbs `Contains` ancestry to an
418 /// Objective/PlanStep. `Contains` points parent → child, so we walk incoming
419 /// edges upward with a visited set (defensive against malformed cycles).
420 fn check_v2_live_operations_rooted(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
421 for node in &snapshot.nodes {
422 if !(matches!(node.kind, NodeKind::Operation) && node.state.is_live()) {
423 continue;
424 }
425 let mut visited: HashSet<&WorkNodeId> = HashSet::new();
426 let mut frontier: Vec<&WorkNodeId> = vec![&node.id];
427 let mut rooted = false;
428 while let Some(current) = frontier.pop() {
429 if !visited.insert(current) {
430 continue;
431 }
432 for edge in &snapshot.edges {
433 if matches!(edge.kind, EdgeKind::Contains)
434 && &edge.to == current
435 && let Some(parent) = snapshot.node(&edge.from)
436 {
437 if matches!(parent.kind, NodeKind::Objective | NodeKind::PlanStep) {
438 rooted = true;
439 }
440 frontier.push(&parent.id);
441 }
442 }
443 if rooted {
444 break;
445 }
446 }
447 if !rooted {
448 out.push(Violation {
449 code: ValidationCode::V2,
450 message: format!(
451 "live operation {} has no Objective/PlanStep ancestry",
452 node.id
453 ),
454 });
455 }
456 }
457 }
458
459 fn check_v3_binding_only_on_operations(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
460 for node in &snapshot.nodes {
461 if node.binding.is_some() && !matches!(node.kind, NodeKind::Operation) {
462 out.push(Violation {
463 code: ValidationCode::V3,
464 message: format!("non-operation node {} carries a binding", node.id),
465 });
466 }
467 }
468 }
469
470 /// V4: `Verified` demands non-empty acceptance and, for every requirement, at
471 /// least one Evidence node linked by a `Verifies` edge whose payload
472 /// satisfies it. There is no fail-open branch: absence of satisfying
473 /// evidence — for any reason, including verification infrastructure being
474 /// unavailable — is a rejection.
475 fn check_v4_verified_requires_evidence(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
476 for node in &snapshot.nodes {
477 if !matches!(node.state, NodeState::Verified) {
478 continue;
479 }
480 if node.acceptance.is_empty() {
481 out.push(Violation {
482 code: ValidationCode::V4,
483 message: format!("verified node {} has no acceptance requirements", node.id),
484 });
485 continue;
486 }
487 let evidence: Vec<&WorkNode> = snapshot
488 .edges
489 .iter()
490 .filter(|e| matches!(e.kind, EdgeKind::Verifies) && e.to == node.id)
491 .filter_map(|e| snapshot.node(&e.from))
492 .filter(|n| matches!(n.kind, NodeKind::Evidence))
493 .collect();
494 for requirement in &node.acceptance {
495 let satisfied = evidence.iter().any(|ev| {
496 ev.evidence
497 .as_ref()
498 .is_some_and(|payload| requirement.is_satisfied_by(payload))
499 });
500 if !satisfied {
501 out.push(Violation {
502 code: ValidationCode::V4,
503 message: format!(
504 "verified node {} lacks satisfying evidence for {:?}",
505 node.id, requirement
506 ),
507 });
508 }
509 }
510 }
511 }
512
513 /// V5: `Blocked` must have a visible cause.
514 fn check_v5_blocked_has_cause(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
515 for node in &snapshot.nodes {
516 if !matches!(node.state, NodeState::Blocked) {
517 continue;
518 }
519 let blocked_by_edge = snapshot
520 .edges
521 .iter()
522 .any(|e| matches!(e.kind, EdgeKind::Blocks) && e.to == node.id);
523 let unmet_dependency = snapshot.edges.iter().any(|e| {
524 matches!(e.kind, EdgeKind::DependsOn)
525 && e.from == node.id
526 && snapshot
527 .node(&e.to)
528 .is_some_and(|dep| !WorkGraphSnapshot::node_is_done(dep))
529 });
530 let pending_approval = snapshot.edges.iter().any(|e| {
531 matches!(e.kind, EdgeKind::RequiresApproval)
532 && e.from == node.id
533 && snapshot
534 .node(&e.to)
535 .is_some_and(|approval| !WorkGraphSnapshot::node_is_done(approval))
536 });
537 if !(blocked_by_edge || unmet_dependency || pending_approval) {
538 out.push(Violation {
539 code: ValidationCode::V5,
540 message: format!("blocked node {} has no blocking cause", node.id),
541 });
542 }
543 }
544 }
545
546 /// V6: binding externals are well-formed under exactly one scheme prefix and
547 /// unique across operations. (Cross-checking against the owners' live
548 /// registries is the liveness slice's job; within the snapshot this is the
549 /// enforceable core.)
550 fn check_v6_binding_identity(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
551 let mut seen: HashMap<&str, &WorkNodeId> = HashMap::new();
552 for node in &snapshot.nodes {
553 let Some(binding) = &node.binding else {
554 continue;
555 };
556 if !external_identity_is_well_formed(&binding.external) {
557 out.push(Violation {
558 code: ValidationCode::V6,
559 message: format!(
560 "node {} binding external {:?} matches no identity scheme",
561 node.id, binding.external
562 ),
563 });
564 }
565 if let Some(previous) = seen.insert(binding.external.as_str(), &node.id) {
566 out.push(Violation {
567 code: ValidationCode::V6,
568 message: format!(
569 "external {:?} bound by both {} and {}",
570 binding.external, previous, node.id
571 ),
572 });
573 }
574 }
575 }
576
577 /// V7: reference nodes are inert — they never carry liveness state, because
578 /// the owning subsystems are the only source of liveness truth.
579 fn check_v7_refs_inert(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
580 for node in &snapshot.nodes {
581 if matches!(node.kind, NodeKind::RuntimeRef | NodeKind::LaneRef)
582 && !matches!(node.state, NodeState::Ready)
583 {
584 out.push(Violation {
585 code: ValidationCode::V7,
586 message: format!(
587 "reference node {} carries liveness state {:?}",
588 node.id, node.state
589 ),
590 });
591 }
592 }
593 }
594
595 /// V8: bounded history with strictly increasing revisions. (The exactly-once
596 /// revision increment itself is a reducer property, covered by tests.)
597 fn check_v8_history_bounded_monotonic(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
598 if snapshot.history.len() > HISTORY_CAP {
599 out.push(Violation {
600 code: ValidationCode::V8,
601 message: format!(
602 "history length {} exceeds bound {HISTORY_CAP}",
603 snapshot.history.len()
604 ),
605 });
606 }
607 let mut previous: Option<u64> = None;
608 for receipt in snapshot.history.iter() {
609 if let Some(prev) = previous
610 && receipt.revision <= prev
611 {
612 out.push(Violation {
613 code: ValidationCode::V8,
614 message: format!(
615 "history revisions not strictly increasing ({} then {})",
616 prev, receipt.revision
617 ),
618 });
619 break;
620 }
621 previous = Some(receipt.revision);
622 }
623 if let Some(last) = snapshot.history.last() {
624 // During apply, validation runs before the increment, so the newest
625 // receipt may equal the current revision but never exceed it by >1.
626 if last.revision > snapshot.revision.saturating_add(1) {
627 out.push(Violation {
628 code: ValidationCode::V8,
629 message: format!(
630 "history revision {} ahead of snapshot revision {}",
631 last.revision, snapshot.revision
632 ),
633 });
634 }
635 }
636 }
637
637 lines RUST