Skip to main content

joy_core/
guard.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Centralized runtime validation for Joy's Trust Model.
5//!
6//! Guard intercepts write operations and checks them against the project's
7//! member capabilities. It is the enforcement point for the Guardianship
8//! pillar of AI Governance (ADR-021).
9//!
10//! Guard is zero-overhead for simple setups: when no members are configured,
11//! or when the acting member has `capabilities: all`, checks return `Allow`
12//! without capability mapping.
13
14use std::collections::BTreeMap;
15use std::path::Path;
16
17use crate::error::JoyError;
18use crate::identity::Identity;
19use crate::model::item::{Capability, Status};
20use crate::model::project::{is_ai_member, Member, MemberCapabilities, Project};
21use crate::store;
22
23/// What operation is being attempted.
24#[derive(Debug, Clone)]
25pub enum Action {
26    CreateItem,
27    UpdateItem,
28    DeleteItem,
29    ChangeStatus {
30        from: Status,
31        to: Status,
32    },
33    AssignItem,
34    AddComment,
35    ManageProject,
36    ManageMilestone,
37    CreateRelease,
38    StartJob {
39        capability: Capability,
40        estimated_cost: Option<f64>,
41    },
42}
43
44impl Action {
45    /// Map this action to the capability required to perform it.
46    /// This is the authoritative source for the action-to-capability mapping.
47    pub fn required_capability(&self) -> Capability {
48        match self {
49            Action::CreateItem => Capability::Create,
50            Action::UpdateItem => Capability::Create,
51            Action::DeleteItem => Capability::Delete,
52            Action::AssignItem => Capability::Assign,
53            Action::AddComment => Capability::Create,
54            Action::ManageProject => Capability::Manage,
55            Action::ManageMilestone => Capability::Manage,
56            Action::CreateRelease => Capability::Manage,
57            Action::ChangeStatus { to, .. } => match to {
58                Status::InProgress => Capability::Implement,
59                Status::Review => Capability::Review,
60                Status::Closed => Capability::Review,
61                Status::Deferred => Capability::Plan,
62                Status::Open => Capability::Plan,
63                Status::New => Capability::Create,
64            },
65            Action::StartJob { capability, .. } => *capability,
66        }
67    }
68}
69
70/// Result of a guard check.
71#[derive(Debug, Clone, PartialEq)]
72pub enum Verdict {
73    Allow,
74    Deny(String),
75    Warn(String),
76}
77
78impl Verdict {
79    pub fn is_allowed(&self) -> bool {
80        matches!(self, Verdict::Allow | Verdict::Warn(_))
81    }
82
83    pub fn is_denied(&self) -> bool {
84        matches!(self, Verdict::Deny(_))
85    }
86
87    /// Convert this verdict into a Result, logging enforcement events.
88    /// Deny becomes an error; Allow and Warn succeed.
89    /// `target` is the item/milestone ID or "project" for management actions.
90    pub fn enforce(self, root: &Path, target: &str, identity: &Identity) -> Result<(), JoyError> {
91        match self {
92            Verdict::Allow => Ok(()),
93            Verdict::Warn(reason) => {
94                crate::event_log::log_event_as(
95                    root,
96                    crate::event_log::EventType::GuardWarned,
97                    target,
98                    Some(&reason),
99                    &identity.log_user(),
100                );
101                eprintln!("Warning: {reason}");
102                Ok(())
103            }
104            Verdict::Deny(reason) => {
105                crate::event_log::log_event_as(
106                    root,
107                    crate::event_log::EventType::GuardDenied,
108                    target,
109                    Some(&reason),
110                    &identity.log_user(),
111                );
112                Err(JoyError::GuardDenied(reason))
113            }
114        }
115    }
116}
117
118/// One-shot guard check: resolve identity from session, check, enforce.
119pub fn enforce(root: &Path, action: &Action, target: &str) -> Result<(), JoyError> {
120    let identity = crate::identity::resolve_identity(root).unwrap_or(Identity {
121        member: "unknown".into(),
122        delegated_by: None,
123        authenticated: false,
124    });
125    Guard::load(root)?
126        .check(action, &identity)
127        .enforce(root, target, &identity)
128}
129
130/// Configuration for a single transition gate.
131#[derive(Debug, Clone)]
132pub struct GateConfig {
133    /// Whether AI members are allowed to perform this transition.
134    pub allow_ai: bool,
135}
136
137/// Centralized runtime validation for the Trust Model.
138pub struct Guard {
139    members: BTreeMap<String, Member>,
140    gates: BTreeMap<String, GateConfig>,
141}
142
143impl Guard {
144    /// Create a Guard from a loaded project (no gates).
145    pub fn new(project: &Project) -> Self {
146        Self {
147            members: project.members.clone(),
148            gates: BTreeMap::new(),
149        }
150    }
151
152    /// Create a Guard with gates.
153    pub fn with_gates(project: &Project, gates: BTreeMap<String, GateConfig>) -> Self {
154        Self {
155            members: project.members.clone(),
156            gates,
157        }
158    }
159
160    /// Load project.yaml and create a Guard, including gate config.
161    pub fn load(root: &Path) -> Result<Self, JoyError> {
162        let project_path = store::joy_dir(root).join(store::PROJECT_FILE);
163        let project = store::read_project(&project_path)?;
164        let gates = load_gates(&project_path)?;
165        Ok(Self::with_gates(&project, gates))
166    }
167
168    /// Get the configured gates (for display in joy project).
169    pub fn gates(&self) -> &BTreeMap<String, GateConfig> {
170        &self.gates
171    }
172
173    /// Check whether an action is allowed for the given identity.
174    pub fn check(&self, action: &Action, identity: &Identity) -> Verdict {
175        // No members configured: no restrictions
176        if self.members.is_empty() {
177            return Verdict::Allow;
178        }
179
180        // Look up the member
181        let member = match self.members.get(&identity.member) {
182            Some(m) => m,
183            None => {
184                return Verdict::Deny(format!(
185                    "{} is not a registered project member",
186                    identity.member
187                ));
188            }
189        };
190
191        // AI-specific restrictions apply regardless of capabilities (even capabilities: all)
192        if is_ai_member(&identity.member) {
193            let required = action.required_capability();
194
195            // AI members are never allowed to perform manage actions
196            if required == Capability::Manage {
197                return Verdict::Deny(format!(
198                    "AI member {} cannot perform manage actions",
199                    identity.member
200                ));
201            }
202
203            // Configurable gates (e.g. allow_ai: false on review->closed)
204            if let Action::ChangeStatus { from, to } = action {
205                let key = format!("{} -> {}", status_str(from), status_str(to));
206                if let Some(gate) = self.gates.get(&key) {
207                    if !gate.allow_ai {
208                        return Verdict::Deny(format!(
209                            "AI member {} blocked by gate on {} (allow_ai: false)",
210                            identity.member, key
211                        ));
212                    }
213                }
214            }
215        }
216
217        // Auth enforcement when AI members exist: all actions require authentication.
218        // This prevents AI tools from piggybacking on a human's file-based session.
219        let has_ai = self.members.keys().any(|k| is_ai_member(k));
220        if has_ai && !identity.authenticated {
221            return Verdict::Deny(format!(
222                "{} must authenticate to perform this action. Run `joy auth`.",
223                identity.member
224            ));
225        }
226
227        // Auth enforcement: manage actions require authentication when auth is active
228        // (even without AI members, manage actions need auth if any member has a key)
229        if !has_ai && !identity.authenticated {
230            let auth_active = self.members.values().any(|m| m.verify_key.is_some());
231            if auth_active {
232                let required = action.required_capability();
233                if required == Capability::Manage {
234                    return Verdict::Deny(format!(
235                        "{} must authenticate to perform manage actions. Run `joy auth`.",
236                        identity.member
237                    ));
238                }
239            }
240        }
241
242        // Fast path: capabilities: all allows everything
243        if member.capabilities == MemberCapabilities::All {
244            return Verdict::Allow;
245        }
246
247        let required = action.required_capability();
248
249        // Check if the member has the required capability
250        if member.has_capability(&required) {
251            // Budget pre-check for StartJob
252            if let Action::StartJob {
253                capability,
254                estimated_cost: Some(cost),
255            } = action
256            {
257                if let MemberCapabilities::Specific(ref map) = member.capabilities {
258                    if let Some(config) = map.get(capability) {
259                        if let Some(max_cost) = config.max_cost_per_job {
260                            if *cost > max_cost {
261                                return Verdict::Deny(format!(
262                                    "{} estimated cost {:.2} exceeds max_cost_per_job {:.2} for '{}'",
263                                    identity.member, cost, max_cost, capability
264                                ));
265                            }
266                        }
267                    }
268                }
269            }
270            Verdict::Allow
271        } else if required.is_management() {
272            // Management actions are hard-denied (not just warned)
273            Verdict::Deny(format!(
274                "{} does not have '{}' capability",
275                identity.member, required
276            ))
277        } else {
278            Verdict::Warn(format!(
279                "{} does not have '{}' capability. \
280                 This action may be rejected by Joy Judge.",
281                identity.member, required
282            ))
283        }
284    }
285
286    /// Check if removing a member would leave no one with manage capability.
287    pub fn is_last_manager(&self, member_id: &str) -> bool {
288        let manager_count = self
289            .members
290            .iter()
291            .filter(|(id, m)| m.has_capability(&Capability::Manage) && !is_ai_member(id))
292            .count();
293        let is_manager = self
294            .members
295            .get(member_id)
296            .map(|m| m.has_capability(&Capability::Manage))
297            .unwrap_or(false);
298        is_manager && manager_count <= 1
299    }
300}
301
302/// Convert Status to the string used in gate config keys.
303pub fn status_str(s: &Status) -> &'static str {
304    match s {
305        Status::New => "new",
306        Status::Open => "open",
307        Status::InProgress => "in-progress",
308        Status::Review => "review",
309        Status::Closed => "closed",
310        Status::Deferred => "deferred",
311    }
312}
313
314/// Load gate config from project.yaml status_rules section.
315fn load_gates(project_path: &Path) -> Result<BTreeMap<String, GateConfig>, JoyError> {
316    let content = std::fs::read_to_string(project_path).map_err(|e| JoyError::ReadFile {
317        path: project_path.to_path_buf(),
318        source: e,
319    })?;
320    let doc: serde_json::Value = serde_yaml_ng::from_str(&content).map_err(JoyError::Yaml)?;
321
322    let mut gates = BTreeMap::new();
323
324    let Some(rules) = doc.get("status_rules") else {
325        return Ok(gates);
326    };
327    let Some(rules_obj) = rules.as_object() else {
328        return Ok(gates);
329    };
330
331    for (key, value) in rules_obj {
332        let allow_ai = value
333            .get("allow_ai")
334            .and_then(|v| v.as_bool())
335            .unwrap_or(true);
336        gates.insert(key.clone(), GateConfig { allow_ai });
337    }
338
339    Ok(gates)
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    fn setup_log_dir(dir: &Path) {
347        std::fs::create_dir_all(dir.join(".joy").join("logs")).unwrap();
348    }
349
350    fn identity(member: &str) -> Identity {
351        Identity {
352            member: member.into(),
353            delegated_by: None,
354            authenticated: true,
355        }
356    }
357
358    fn unauthenticated_identity(member: &str) -> Identity {
359        Identity {
360            member: member.into(),
361            delegated_by: None,
362            authenticated: false,
363        }
364    }
365
366    fn ai_identity(member: &str, delegated_by: &str) -> Identity {
367        Identity {
368            member: member.into(),
369            delegated_by: Some(delegated_by.into()),
370            authenticated: true,
371        }
372    }
373
374    fn project_with_members(members: Vec<(&str, MemberCapabilities)>) -> Project {
375        let mut project = Project::new("Test".into(), Some("TST".into()));
376        for (name, caps) in members {
377            project.members.insert(name.into(), Member::new(caps));
378        }
379        project
380    }
381
382    fn specific_caps(caps: &[Capability]) -> MemberCapabilities {
383        let map: BTreeMap<Capability, _> = caps.iter().map(|c| (*c, Default::default())).collect();
384        MemberCapabilities::Specific(map)
385    }
386
387    #[test]
388    fn no_members_allows_all() {
389        let project = Project::new("Test".into(), Some("TST".into()));
390        let guard = Guard::new(&project);
391        let id = identity("anyone@example.com");
392
393        assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
394        assert_eq!(guard.check(&Action::ManageProject, &id), Verdict::Allow);
395        assert_eq!(guard.check(&Action::DeleteItem, &id), Verdict::Allow);
396    }
397
398    #[test]
399    fn member_with_all_caps() {
400        let project = project_with_members(vec![("dev@example.com", MemberCapabilities::All)]);
401        let guard = Guard::new(&project);
402        let id = identity("dev@example.com");
403
404        assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
405        assert_eq!(guard.check(&Action::ManageProject, &id), Verdict::Allow);
406        assert_eq!(guard.check(&Action::DeleteItem, &id), Verdict::Allow);
407        assert_eq!(
408            guard.check(
409                &Action::ChangeStatus {
410                    from: Status::New,
411                    to: Status::InProgress
412                },
413                &id
414            ),
415            Verdict::Allow
416        );
417    }
418
419    #[test]
420    fn member_with_specific_caps() {
421        let project = project_with_members(vec![(
422            "dev@example.com",
423            specific_caps(&[Capability::Implement, Capability::Create]),
424        )]);
425        let guard = Guard::new(&project);
426        let id = identity("dev@example.com");
427
428        // Has Create -> Allow
429        assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
430
431        // Has Implement -> ChangeStatus to InProgress = Allow
432        assert_eq!(
433            guard.check(
434                &Action::ChangeStatus {
435                    from: Status::Open,
436                    to: Status::InProgress
437                },
438                &id
439            ),
440            Verdict::Allow
441        );
442
443        // Lacks Delete -> Deny (management capability)
444        assert!(matches!(
445            guard.check(&Action::DeleteItem, &id),
446            Verdict::Deny(_)
447        ));
448
449        // Lacks Manage -> Deny (management actions are hard-denied)
450        assert!(matches!(
451            guard.check(&Action::ManageProject, &id),
452            Verdict::Deny(_)
453        ));
454
455        // Lacks Review -> ChangeStatus to Closed = Warn
456        assert!(matches!(
457            guard.check(
458                &Action::ChangeStatus {
459                    from: Status::Review,
460                    to: Status::Closed
461                },
462                &id
463            ),
464            Verdict::Warn(_)
465        ));
466    }
467
468    #[test]
469    fn ai_member_blocked_from_manage() {
470        let project = project_with_members(vec![
471            ("dev@example.com", MemberCapabilities::All),
472            (
473                "ai:claude@joy",
474                specific_caps(&[
475                    Capability::Implement,
476                    Capability::Review,
477                    Capability::Create,
478                ]),
479            ),
480        ]);
481        let guard = Guard::new(&project);
482        let id = ai_identity("ai:claude@joy", "dev@example.com");
483
484        // AI with Create -> CreateItem = Allow
485        assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
486
487        // AI with Implement -> ChangeStatus to InProgress = Allow
488        assert_eq!(
489            guard.check(
490                &Action::ChangeStatus {
491                    from: Status::Open,
492                    to: Status::InProgress
493                },
494                &id
495            ),
496            Verdict::Allow
497        );
498
499        // AI attempting Manage -> Deny (regardless of capabilities)
500        assert!(matches!(
501            guard.check(&Action::ManageProject, &id),
502            Verdict::Deny(_)
503        ));
504        assert!(matches!(
505            guard.check(&Action::ManageMilestone, &id),
506            Verdict::Deny(_)
507        ));
508        assert!(matches!(
509            guard.check(&Action::CreateRelease, &id),
510            Verdict::Deny(_)
511        ));
512    }
513
514    #[test]
515    fn gate_blocks_ai_on_configured_transition() {
516        let project = project_with_members(vec![
517            ("dev@example.com", MemberCapabilities::All),
518            (
519                "ai:claude@joy",
520                specific_caps(&[
521                    Capability::Implement,
522                    Capability::Review,
523                    Capability::Create,
524                ]),
525            ),
526        ]);
527        let mut gates = BTreeMap::new();
528        gates.insert(
529            "review -> closed".to_string(),
530            GateConfig { allow_ai: false },
531        );
532        let guard = Guard::with_gates(&project, gates);
533        let ai = ai_identity("ai:claude@joy", "dev@example.com");
534        let human = identity("dev@example.com");
535
536        // AI blocked by gate
537        assert!(matches!(
538            guard.check(
539                &Action::ChangeStatus {
540                    from: Status::Review,
541                    to: Status::Closed
542                },
543                &ai
544            ),
545            Verdict::Deny(_)
546        ));
547
548        // Human not blocked
549        assert_eq!(
550            guard.check(
551                &Action::ChangeStatus {
552                    from: Status::Review,
553                    to: Status::Closed
554                },
555                &human
556            ),
557            Verdict::Allow
558        );
559
560        // AI can still do other transitions
561        assert_eq!(
562            guard.check(
563                &Action::ChangeStatus {
564                    from: Status::InProgress,
565                    to: Status::Review
566                },
567                &ai
568            ),
569            Verdict::Allow
570        );
571    }
572
573    #[test]
574    fn no_gates_allows_all_transitions() {
575        let project = project_with_members(vec![(
576            "ai:claude@joy",
577            specific_caps(&[Capability::Review, Capability::Create]),
578        )]);
579        let guard = Guard::new(&project); // no gates
580        let ai = ai_identity("ai:claude@joy", "dev@example.com");
581
582        // Without gates, AI with Review can close
583        assert_eq!(
584            guard.check(
585                &Action::ChangeStatus {
586                    from: Status::Review,
587                    to: Status::Closed
588                },
589                &ai
590            ),
591            Verdict::Allow
592        );
593    }
594
595    #[test]
596    fn ai_member_can_close_without_gate() {
597        let project = project_with_members(vec![
598            ("dev@example.com", MemberCapabilities::All),
599            (
600                "ai:claude@joy",
601                specific_caps(&[
602                    Capability::Implement,
603                    Capability::Review,
604                    Capability::Create,
605                ]),
606            ),
607        ]);
608        let guard = Guard::new(&project);
609        let ai = ai_identity("ai:claude@joy", "dev@example.com");
610        let human = identity("dev@example.com");
611
612        // Without gate config, AI with Review capability CAN close items
613        assert_eq!(
614            guard.check(
615                &Action::ChangeStatus {
616                    from: Status::Review,
617                    to: Status::Closed
618                },
619                &ai
620            ),
621            Verdict::Allow
622        );
623
624        // AI can submit for review
625        assert_eq!(
626            guard.check(
627                &Action::ChangeStatus {
628                    from: Status::InProgress,
629                    to: Status::Review
630                },
631                &ai
632            ),
633            Verdict::Allow
634        );
635
636        // Human can close items
637        assert_eq!(
638            guard.check(
639                &Action::ChangeStatus {
640                    from: Status::Review,
641                    to: Status::Closed
642                },
643                &human
644            ),
645            Verdict::Allow
646        );
647    }
648
649    #[test]
650    fn unknown_member_denied() {
651        let project = project_with_members(vec![("dev@example.com", MemberCapabilities::All)]);
652        let guard = Guard::new(&project);
653        let id = identity("stranger@example.com");
654
655        assert!(matches!(
656            guard.check(&Action::CreateItem, &id),
657            Verdict::Deny(_)
658        ));
659    }
660
661    #[test]
662    fn status_transitions_require_correct_cap() {
663        let project = project_with_members(vec![(
664            "dev@example.com",
665            specific_caps(&[Capability::Implement, Capability::Create]),
666        )]);
667        let guard = Guard::new(&project);
668        let id = identity("dev@example.com");
669
670        let check_transition = |to: Status| -> Verdict {
671            guard.check(
672                &Action::ChangeStatus {
673                    from: Status::New,
674                    to,
675                },
676                &id,
677            )
678        };
679
680        // InProgress needs Implement -> Allow
681        assert_eq!(check_transition(Status::InProgress), Verdict::Allow);
682        // New needs Create -> Allow
683        assert_eq!(check_transition(Status::New), Verdict::Allow);
684        // Review needs Review -> Warn (missing)
685        assert!(matches!(check_transition(Status::Review), Verdict::Warn(_)));
686        // Closed needs Review -> Warn (missing)
687        assert!(matches!(check_transition(Status::Closed), Verdict::Warn(_)));
688        // Open needs Plan -> Warn (missing)
689        assert!(matches!(check_transition(Status::Open), Verdict::Warn(_)));
690        // Deferred needs Plan -> Warn (missing)
691        assert!(matches!(
692            check_transition(Status::Deferred),
693            Verdict::Warn(_)
694        ));
695    }
696
697    /// Integration test: realistic team with lead, developer, and AI agent.
698    /// Verifies the full gate enforcement across a typical workflow.
699    #[test]
700    fn team_workflow_gate_enforcement() {
701        let project = project_with_members(vec![
702            // Lead: full access
703            ("lead@example.com", MemberCapabilities::All),
704            // Developer: can implement, test, create, but not review or manage
705            (
706                "dev@example.com",
707                specific_caps(&[Capability::Implement, Capability::Test, Capability::Create]),
708            ),
709            // AI agent: can implement, review, create
710            (
711                "ai:claude@joy",
712                specific_caps(&[
713                    Capability::Implement,
714                    Capability::Review,
715                    Capability::Create,
716                ]),
717            ),
718        ]);
719        let guard = Guard::new(&project);
720
721        let lead = identity("lead@example.com");
722        let dev = identity("dev@example.com");
723        let ai = ai_identity("ai:claude@joy", "lead@example.com");
724
725        // === Creating items ===
726        // All three can create (all have Create)
727        assert_eq!(guard.check(&Action::CreateItem, &lead), Verdict::Allow);
728        assert_eq!(guard.check(&Action::CreateItem, &dev), Verdict::Allow);
729        assert_eq!(guard.check(&Action::CreateItem, &ai), Verdict::Allow);
730
731        // === Starting work (-> InProgress needs Implement) ===
732        let start = Action::ChangeStatus {
733            from: Status::Open,
734            to: Status::InProgress,
735        };
736        assert_eq!(guard.check(&start, &lead), Verdict::Allow);
737        assert_eq!(guard.check(&start, &dev), Verdict::Allow);
738        assert_eq!(guard.check(&start, &ai), Verdict::Allow);
739
740        // === Submitting for review (-> Review needs Review) ===
741        let submit = Action::ChangeStatus {
742            from: Status::InProgress,
743            to: Status::Review,
744        };
745        assert_eq!(guard.check(&submit, &lead), Verdict::Allow);
746        // Dev lacks Review -> Warn
747        assert!(matches!(guard.check(&submit, &dev), Verdict::Warn(_)));
748        // AI has Review -> Allow
749        assert_eq!(guard.check(&submit, &ai), Verdict::Allow);
750
751        // === Closing items (-> Closed needs Review + acceptance gate) ===
752        let close = Action::ChangeStatus {
753            from: Status::Review,
754            to: Status::Closed,
755        };
756        // Lead can close (capabilities: all)
757        assert_eq!(guard.check(&close, &lead), Verdict::Allow);
758        // Dev lacks Review -> Warn
759        assert!(matches!(guard.check(&close, &dev), Verdict::Warn(_)));
760        // Without gate config, AI with Review CAN close
761        assert_eq!(guard.check(&close, &ai), Verdict::Allow);
762
763        // === Managing project ===
764        // Lead can manage
765        assert_eq!(guard.check(&Action::ManageProject, &lead), Verdict::Allow);
766        // Dev lacks Manage -> Deny (management actions are hard-denied)
767        assert!(matches!(
768            guard.check(&Action::ManageProject, &dev),
769            Verdict::Deny(_)
770        ));
771        // AI cannot manage -> Deny
772        assert!(matches!(
773            guard.check(&Action::ManageProject, &ai),
774            Verdict::Deny(_)
775        ));
776    }
777
778    #[test]
779    fn required_capability_mapping_is_complete() {
780        // Verify every action maps to the expected capability
781        assert_eq!(Action::CreateItem.required_capability(), Capability::Create);
782        assert_eq!(Action::UpdateItem.required_capability(), Capability::Create);
783        assert_eq!(Action::DeleteItem.required_capability(), Capability::Delete);
784        assert_eq!(Action::AssignItem.required_capability(), Capability::Assign);
785        assert_eq!(Action::AddComment.required_capability(), Capability::Create);
786        assert_eq!(
787            Action::ManageProject.required_capability(),
788            Capability::Manage
789        );
790        assert_eq!(
791            Action::ManageMilestone.required_capability(),
792            Capability::Manage
793        );
794        assert_eq!(
795            Action::CreateRelease.required_capability(),
796            Capability::Manage
797        );
798
799        // Status transitions
800        let cs = |to: Status| Action::ChangeStatus {
801            from: Status::New,
802            to,
803        };
804        assert_eq!(
805            cs(Status::InProgress).required_capability(),
806            Capability::Implement
807        );
808        assert_eq!(cs(Status::Review).required_capability(), Capability::Review);
809        assert_eq!(cs(Status::Closed).required_capability(), Capability::Review);
810        assert_eq!(cs(Status::Deferred).required_capability(), Capability::Plan);
811        assert_eq!(cs(Status::Open).required_capability(), Capability::Plan);
812        assert_eq!(cs(Status::New).required_capability(), Capability::Create);
813
814        // StartJob delegates to its capability
815        assert_eq!(
816            Action::StartJob {
817                capability: Capability::Implement,
818                estimated_cost: None
819            }
820            .required_capability(),
821            Capability::Implement
822        );
823    }
824
825    #[test]
826    fn verdict_enforce_allow() {
827        let dir = tempfile::tempdir().unwrap();
828        setup_log_dir(dir.path());
829        let id = identity("dev@example.com");
830        assert!(Verdict::Allow.enforce(dir.path(), "TST-0001", &id).is_ok());
831    }
832
833    #[test]
834    fn verdict_enforce_deny_logs_event() {
835        let dir = tempfile::tempdir().unwrap();
836        setup_log_dir(dir.path());
837        let id = identity("dev@example.com");
838        let result = Verdict::Deny("blocked".into()).enforce(dir.path(), "TST-0001", &id);
839        assert!(result.is_err());
840        // Verify event was logged
841        let events = crate::event_log::read_events(dir.path(), None, None, 100).unwrap();
842        assert_eq!(events.len(), 1);
843        assert_eq!(events[0].event_type, "guard.denied");
844        assert_eq!(events[0].target, "TST-0001");
845        assert_eq!(events[0].details.as_deref(), Some("blocked"));
846    }
847
848    #[test]
849    fn verdict_enforce_warn_logs_event() {
850        let dir = tempfile::tempdir().unwrap();
851        setup_log_dir(dir.path());
852        let id = identity("dev@example.com");
853        let result = Verdict::Warn("caution".into()).enforce(dir.path(), "TST-0001", &id);
854        assert!(result.is_ok());
855        // Verify event was logged
856        let events = crate::event_log::read_events(dir.path(), None, None, 100).unwrap();
857        assert_eq!(events.len(), 1);
858        assert_eq!(events[0].event_type, "guard.warned");
859        assert_eq!(events[0].target, "TST-0001");
860        assert_eq!(events[0].details.as_deref(), Some("caution"));
861    }
862
863    #[test]
864    fn budget_precheck_allows_within_limit() {
865        let mut caps = BTreeMap::new();
866        caps.insert(
867            Capability::Implement,
868            crate::model::project::CapabilityConfig {
869                max_mode: None,
870                max_cost_per_job: Some(5.0),
871            },
872        );
873        let project =
874            project_with_members(vec![("ai:claude@joy", MemberCapabilities::Specific(caps))]);
875        let guard = Guard::new(&project);
876        let ai = ai_identity("ai:claude@joy", "dev@example.com");
877
878        // Within budget -> Allow
879        assert_eq!(
880            guard.check(
881                &Action::StartJob {
882                    capability: Capability::Implement,
883                    estimated_cost: Some(3.0),
884                },
885                &ai
886            ),
887            Verdict::Allow
888        );
889
890        // Exactly at limit -> Allow
891        assert_eq!(
892            guard.check(
893                &Action::StartJob {
894                    capability: Capability::Implement,
895                    estimated_cost: Some(5.0),
896                },
897                &ai
898            ),
899            Verdict::Allow
900        );
901    }
902
903    #[test]
904    fn budget_precheck_denies_over_limit() {
905        let mut caps = BTreeMap::new();
906        caps.insert(
907            Capability::Implement,
908            crate::model::project::CapabilityConfig {
909                max_mode: None,
910                max_cost_per_job: Some(5.0),
911            },
912        );
913        let project =
914            project_with_members(vec![("ai:claude@joy", MemberCapabilities::Specific(caps))]);
915        let guard = Guard::new(&project);
916        let ai = ai_identity("ai:claude@joy", "dev@example.com");
917
918        // Over budget -> Deny
919        let verdict = guard.check(
920            &Action::StartJob {
921                capability: Capability::Implement,
922                estimated_cost: Some(7.50),
923            },
924            &ai,
925        );
926        assert!(matches!(verdict, Verdict::Deny(_)));
927        if let Verdict::Deny(reason) = verdict {
928            assert!(reason.contains("7.50"));
929            assert!(reason.contains("5.00"));
930        }
931    }
932
933    #[test]
934    fn budget_precheck_allows_without_cost_limit() {
935        let project = project_with_members(vec![(
936            "ai:claude@joy",
937            specific_caps(&[Capability::Implement]),
938        )]);
939        let guard = Guard::new(&project);
940        let ai = ai_identity("ai:claude@joy", "dev@example.com");
941
942        // No max_cost_per_job configured -> Allow regardless of cost
943        assert_eq!(
944            guard.check(
945                &Action::StartJob {
946                    capability: Capability::Implement,
947                    estimated_cost: Some(999.0),
948                },
949                &ai
950            ),
951            Verdict::Allow
952        );
953    }
954
955    #[test]
956    fn budget_precheck_allows_without_estimate() {
957        let mut caps = BTreeMap::new();
958        caps.insert(
959            Capability::Implement,
960            crate::model::project::CapabilityConfig {
961                max_mode: None,
962                max_cost_per_job: Some(5.0),
963            },
964        );
965        let project =
966            project_with_members(vec![("ai:claude@joy", MemberCapabilities::Specific(caps))]);
967        let guard = Guard::new(&project);
968        let ai = ai_identity("ai:claude@joy", "dev@example.com");
969
970        // No estimated cost -> Allow (can't pre-check what we don't know)
971        assert_eq!(
972            guard.check(
973                &Action::StartJob {
974                    capability: Capability::Implement,
975                    estimated_cost: None,
976                },
977                &ai
978            ),
979            Verdict::Allow
980        );
981    }
982
983    #[test]
984    fn is_last_manager_solo() {
985        let project = project_with_members(vec![("lead@example.com", MemberCapabilities::All)]);
986        let guard = Guard::new(&project);
987        assert!(guard.is_last_manager("lead@example.com"));
988    }
989
990    #[test]
991    fn is_last_manager_with_backup() {
992        let project = project_with_members(vec![
993            ("lead@example.com", MemberCapabilities::All),
994            ("backup@example.com", MemberCapabilities::All),
995        ]);
996        let guard = Guard::new(&project);
997        assert!(!guard.is_last_manager("lead@example.com"));
998        assert!(!guard.is_last_manager("backup@example.com"));
999    }
1000
1001    #[test]
1002    fn is_last_manager_ai_not_counted() {
1003        let project = project_with_members(vec![
1004            ("lead@example.com", MemberCapabilities::All),
1005            ("ai:claude@joy", MemberCapabilities::All),
1006        ]);
1007        let guard = Guard::new(&project);
1008        // AI members don't count as managers (Guard blocks AI from manage)
1009        assert!(guard.is_last_manager("lead@example.com"));
1010    }
1011
1012    #[test]
1013    fn is_last_manager_non_manager_member() {
1014        let project = project_with_members(vec![
1015            ("lead@example.com", MemberCapabilities::All),
1016            (
1017                "dev@example.com",
1018                specific_caps(&[Capability::Implement, Capability::Create]),
1019            ),
1020        ]);
1021        let guard = Guard::new(&project);
1022        // lead is the only manager
1023        assert!(guard.is_last_manager("lead@example.com"));
1024        // dev is not a manager
1025        assert!(!guard.is_last_manager("dev@example.com"));
1026    }
1027
1028    #[test]
1029    fn unauthenticated_denied_when_ai_members_exist() {
1030        let project = project_with_members(vec![
1031            ("dev@example.com", MemberCapabilities::All),
1032            (
1033                "ai:claude@joy",
1034                specific_caps(&[Capability::Implement, Capability::Create]),
1035            ),
1036        ]);
1037        let guard = Guard::new(&project);
1038        let unauth = unauthenticated_identity("dev@example.com");
1039
1040        // All write actions denied for unauthenticated members
1041        assert!(matches!(
1042            guard.check(&Action::CreateItem, &unauth),
1043            Verdict::Deny(_)
1044        ));
1045        assert!(matches!(
1046            guard.check(&Action::AddComment, &unauth),
1047            Verdict::Deny(_)
1048        ));
1049        assert!(matches!(
1050            guard.check(
1051                &Action::ChangeStatus {
1052                    from: Status::Open,
1053                    to: Status::InProgress
1054                },
1055                &unauth
1056            ),
1057            Verdict::Deny(_)
1058        ));
1059
1060        // Authenticated member is allowed
1061        let auth = identity("dev@example.com");
1062        assert_eq!(guard.check(&Action::CreateItem, &auth), Verdict::Allow);
1063    }
1064
1065    #[test]
1066    fn unauthenticated_allowed_without_ai_members() {
1067        let project = project_with_members(vec![("dev@example.com", MemberCapabilities::All)]);
1068        let guard = Guard::new(&project);
1069        let unauth = unauthenticated_identity("dev@example.com");
1070
1071        // No AI members -> unauthenticated access allowed (frictionless solo)
1072        assert_eq!(guard.check(&Action::CreateItem, &unauth), Verdict::Allow);
1073        assert_eq!(guard.check(&Action::AddComment, &unauth), Verdict::Allow);
1074    }
1075
1076    #[test]
1077    fn verdict_helpers() {
1078        assert!(Verdict::Allow.is_allowed());
1079        assert!(!Verdict::Allow.is_denied());
1080
1081        assert!(Verdict::Warn("w".into()).is_allowed());
1082        assert!(!Verdict::Warn("w".into()).is_denied());
1083
1084        assert!(!Verdict::Deny("d".into()).is_allowed());
1085        assert!(Verdict::Deny("d".into()).is_denied());
1086    }
1087}