返回 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::ApiProvider::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::ApiProvider::Custom => Some(super::ReasoningEffortTier::Unavailable),
330 api_provider => super::model::constrained_effective_reasoning_for_route(
331 *requested,
332 api_provider,
333 endpoint_identity.as_deref().expect("bounded above"),
334 model.as_deref().expect("bounded above"),
335 ),
336 };
337 if constrained.is_some_and(|expected| *effective != expected) {
338 out.push(Violation {
339 code: ValidationCode::Structural,
340 message: "activity effective reasoning is impossible for its recorded route"
341 .to_string(),
342 });
343 } else if constrained.is_none()
344 && matches!(
345 effective,
346 super::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable
347 )
348 {
349 out.push(Violation {
350 code: ValidationCode::Structural,
351 message: "granularity-unavailable receipt is not valid for this recorded route"
352 .to_string(),
353 });
354 }
355 }
356 if let Some(operation) = operation {
357 match snapshot.node(operation) {
358 Some(node) if node.kind == NodeKind::Operation => {}
359 Some(_) => out.push(Violation {
360 code: ValidationCode::Structural,
361 message: format!("activity operation {operation} is not an Operation node"),
362 }),
363 None => out.push(Violation {
364 code: ValidationCode::Structural,
365 message: format!("activity references missing operation {operation}"),
366 }),
367 }
368 }
369 }
370 }
371
372 /// V1: DFS three-color cycle detection over `DependsOn` edges.
373 fn check_v1_depends_on_acyclic(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
374 let mut adjacency: HashMap<&WorkNodeId, Vec<&WorkNodeId>> = HashMap::new();
375 for edge in &snapshot.edges {
376 if matches!(edge.kind, EdgeKind::DependsOn) {
377 adjacency.entry(&edge.from).or_default().push(&edge.to);
378 }
379 }
380 let mut done: HashSet<&WorkNodeId> = HashSet::new();
381 let mut in_progress: HashSet<&WorkNodeId> = HashSet::new();
382
383 fn visit<'a>(
384 node: &'a WorkNodeId,
385 adjacency: &HashMap<&'a WorkNodeId, Vec<&'a WorkNodeId>>,
386 done: &mut HashSet<&'a WorkNodeId>,
387 in_progress: &mut HashSet<&'a WorkNodeId>,
388 ) -> bool {
389 if done.contains(node) {
390 return true;
391 }
392 if !in_progress.insert(node) {
393 return false; // back edge → cycle
394 }
395 let acyclic = adjacency
396 .get(node)
397 .map(|next| next.iter().all(|n| visit(n, adjacency, done, in_progress)))
398 .unwrap_or(true);
399 in_progress.remove(node);
400 done.insert(node);
401 acyclic
402 }
403
404 for node in &snapshot.nodes {
405 if !visit(&node.id, &adjacency, &mut done, &mut in_progress) {
406 out.push(Violation {
407 code: ValidationCode::V1,
408 message: format!("depends_on cycle reachable from node {}", node.id),
409 });
410 return; // one report is enough; graph is already invalid
411 }
412 }
413 }
414
415 /// V2: every live Operation climbs `Contains` ancestry to an
416 /// Objective/PlanStep. `Contains` points parent → child, so we walk incoming
417 /// edges upward with a visited set (defensive against malformed cycles).
418 fn check_v2_live_operations_rooted(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
419 for node in &snapshot.nodes {
420 if !(matches!(node.kind, NodeKind::Operation) && node.state.is_live()) {
421 continue;
422 }
423 let mut visited: HashSet<&WorkNodeId> = HashSet::new();
424 let mut frontier: Vec<&WorkNodeId> = vec![&node.id];
425 let mut rooted = false;
426 while let Some(current) = frontier.pop() {
427 if !visited.insert(current) {
428 continue;
429 }
430 for edge in &snapshot.edges {
431 if matches!(edge.kind, EdgeKind::Contains)
432 && &edge.to == current
433 && let Some(parent) = snapshot.node(&edge.from)
434 {
435 if matches!(parent.kind, NodeKind::Objective | NodeKind::PlanStep) {
436 rooted = true;
437 }
438 frontier.push(&parent.id);
439 }
440 }
441 if rooted {
442 break;
443 }
444 }
445 if !rooted {
446 out.push(Violation {
447 code: ValidationCode::V2,
448 message: format!(
449 "live operation {} has no Objective/PlanStep ancestry",
450 node.id
451 ),
452 });
453 }
454 }
455 }
456
457 fn check_v3_binding_only_on_operations(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
458 for node in &snapshot.nodes {
459 if node.binding.is_some() && !matches!(node.kind, NodeKind::Operation) {
460 out.push(Violation {
461 code: ValidationCode::V3,
462 message: format!("non-operation node {} carries a binding", node.id),
463 });
464 }
465 }
466 }
467
468 /// V4: `Verified` demands non-empty acceptance and, for every requirement, at
469 /// least one Evidence node linked by a `Verifies` edge whose payload
470 /// satisfies it. There is no fail-open branch: absence of satisfying
471 /// evidence — for any reason, including verification infrastructure being
472 /// unavailable — is a rejection.
473 fn check_v4_verified_requires_evidence(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
474 for node in &snapshot.nodes {
475 if !matches!(node.state, NodeState::Verified) {
476 continue;
477 }
478 if node.acceptance.is_empty() {
479 out.push(Violation {
480 code: ValidationCode::V4,
481 message: format!("verified node {} has no acceptance requirements", node.id),
482 });
483 continue;
484 }
485 let evidence: Vec<&WorkNode> = snapshot
486 .edges
487 .iter()
488 .filter(|e| matches!(e.kind, EdgeKind::Verifies) && e.to == node.id)
489 .filter_map(|e| snapshot.node(&e.from))
490 .filter(|n| matches!(n.kind, NodeKind::Evidence))
491 .collect();
492 for requirement in &node.acceptance {
493 let satisfied = evidence.iter().any(|ev| {
494 ev.evidence
495 .as_ref()
496 .is_some_and(|payload| requirement.is_satisfied_by(payload))
497 });
498 if !satisfied {
499 out.push(Violation {
500 code: ValidationCode::V4,
501 message: format!(
502 "verified node {} lacks satisfying evidence for {:?}",
503 node.id, requirement
504 ),
505 });
506 }
507 }
508 }
509 }
510
511 /// V5: `Blocked` must have a visible cause.
512 fn check_v5_blocked_has_cause(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
513 for node in &snapshot.nodes {
514 if !matches!(node.state, NodeState::Blocked) {
515 continue;
516 }
517 let blocked_by_edge = snapshot
518 .edges
519 .iter()
520 .any(|e| matches!(e.kind, EdgeKind::Blocks) && e.to == node.id);
521 let unmet_dependency = snapshot.edges.iter().any(|e| {
522 matches!(e.kind, EdgeKind::DependsOn)
523 && e.from == node.id
524 && snapshot
525 .node(&e.to)
526 .is_some_and(|dep| !WorkGraphSnapshot::node_is_done(dep))
527 });
528 let pending_approval = snapshot.edges.iter().any(|e| {
529 matches!(e.kind, EdgeKind::RequiresApproval)
530 && e.from == node.id
531 && snapshot
532 .node(&e.to)
533 .is_some_and(|approval| !WorkGraphSnapshot::node_is_done(approval))
534 });
535 if !(blocked_by_edge || unmet_dependency || pending_approval) {
536 out.push(Violation {
537 code: ValidationCode::V5,
538 message: format!("blocked node {} has no blocking cause", node.id),
539 });
540 }
541 }
542 }
543
544 /// V6: binding externals are well-formed under exactly one scheme prefix and
545 /// unique across operations. (Cross-checking against the owners' live
546 /// registries is the liveness slice's job; within the snapshot this is the
547 /// enforceable core.)
548 fn check_v6_binding_identity(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
549 let mut seen: HashMap<&str, &WorkNodeId> = HashMap::new();
550 for node in &snapshot.nodes {
551 let Some(binding) = &node.binding else {
552 continue;
553 };
554 if !external_identity_is_well_formed(&binding.external) {
555 out.push(Violation {
556 code: ValidationCode::V6,
557 message: format!(
558 "node {} binding external {:?} matches no identity scheme",
559 node.id, binding.external
560 ),
561 });
562 }
563 if let Some(previous) = seen.insert(binding.external.as_str(), &node.id) {
564 out.push(Violation {
565 code: ValidationCode::V6,
566 message: format!(
567 "external {:?} bound by both {} and {}",
568 binding.external, previous, node.id
569 ),
570 });
571 }
572 }
573 }
574
575 /// V7: reference nodes are inert — they never carry liveness state, because
576 /// the owning subsystems are the only source of liveness truth.
577 fn check_v7_refs_inert(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
578 for node in &snapshot.nodes {
579 if matches!(node.kind, NodeKind::RuntimeRef | NodeKind::LaneRef)
580 && !matches!(node.state, NodeState::Ready)
581 {
582 out.push(Violation {
583 code: ValidationCode::V7,
584 message: format!(
585 "reference node {} carries liveness state {:?}",
586 node.id, node.state
587 ),
588 });
589 }
590 }
591 }
592
593 /// V8: bounded history with strictly increasing revisions. (The exactly-once
594 /// revision increment itself is a reducer property, covered by tests.)
595 fn check_v8_history_bounded_monotonic(snapshot: &WorkGraphSnapshot, out: &mut Vec<Violation>) {
596 if snapshot.history.len() > HISTORY_CAP {
597 out.push(Violation {
598 code: ValidationCode::V8,
599 message: format!(
600 "history length {} exceeds bound {HISTORY_CAP}",
601 snapshot.history.len()
602 ),
603 });
604 }
605 let mut previous: Option<u64> = None;
606 for receipt in snapshot.history.iter() {
607 if let Some(prev) = previous
608 && receipt.revision <= prev
609 {
610 out.push(Violation {
611 code: ValidationCode::V8,
612 message: format!(
613 "history revisions not strictly increasing ({} then {})",
614 prev, receipt.revision
615 ),
616 });
617 break;
618 }
619 previous = Some(receipt.revision);
620 }
621 if let Some(last) = snapshot.history.last() {
622 // During apply, validation runs before the increment, so the newest
623 // receipt may equal the current revision but never exceed it by >1.
624 if last.revision > snapshot.revision.saturating_add(1) {
625 out.push(Violation {
626 code: ValidationCode::V8,
627 message: format!(
628 "history revision {} ahead of snapshot revision {}",
629 last.revision, snapshot.revision
630 ),
631 });
632 }
633 }
634 }
635
635 lines RUST