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: Project = store::read_yaml(&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.public_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
384            .iter()
385            .map(|c| (c.clone(), Default::default()))
386            .collect();
387        MemberCapabilities::Specific(map)
388    }
389
390    #[test]
391    fn no_members_allows_all() {
392        let project = Project::new("Test".into(), Some("TST".into()));
393        let guard = Guard::new(&project);
394        let id = identity("anyone@example.com");
395
396        assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
397        assert_eq!(guard.check(&Action::ManageProject, &id), Verdict::Allow);
398        assert_eq!(guard.check(&Action::DeleteItem, &id), Verdict::Allow);
399    }
400
401    #[test]
402    fn member_with_all_caps() {
403        let project = project_with_members(vec![("dev@example.com", MemberCapabilities::All)]);
404        let guard = Guard::new(&project);
405        let id = identity("dev@example.com");
406
407        assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
408        assert_eq!(guard.check(&Action::ManageProject, &id), Verdict::Allow);
409        assert_eq!(guard.check(&Action::DeleteItem, &id), Verdict::Allow);
410        assert_eq!(
411            guard.check(
412                &Action::ChangeStatus {
413                    from: Status::New,
414                    to: Status::InProgress
415                },
416                &id
417            ),
418            Verdict::Allow
419        );
420    }
421
422    #[test]
423    fn member_with_specific_caps() {
424        let project = project_with_members(vec![(
425            "dev@example.com",
426            specific_caps(&[Capability::Implement, Capability::Create]),
427        )]);
428        let guard = Guard::new(&project);
429        let id = identity("dev@example.com");
430
431        // Has Create -> Allow
432        assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
433
434        // Has Implement -> ChangeStatus to InProgress = Allow
435        assert_eq!(
436            guard.check(
437                &Action::ChangeStatus {
438                    from: Status::Open,
439                    to: Status::InProgress
440                },
441                &id
442            ),
443            Verdict::Allow
444        );
445
446        // Lacks Delete -> Deny (management capability)
447        assert!(matches!(
448            guard.check(&Action::DeleteItem, &id),
449            Verdict::Deny(_)
450        ));
451
452        // Lacks Manage -> Deny (management actions are hard-denied)
453        assert!(matches!(
454            guard.check(&Action::ManageProject, &id),
455            Verdict::Deny(_)
456        ));
457
458        // Lacks Review -> ChangeStatus to Closed = Warn
459        assert!(matches!(
460            guard.check(
461                &Action::ChangeStatus {
462                    from: Status::Review,
463                    to: Status::Closed
464                },
465                &id
466            ),
467            Verdict::Warn(_)
468        ));
469    }
470
471    #[test]
472    fn ai_member_blocked_from_manage() {
473        let project = project_with_members(vec![
474            ("dev@example.com", MemberCapabilities::All),
475            (
476                "ai:claude@joy",
477                specific_caps(&[
478                    Capability::Implement,
479                    Capability::Review,
480                    Capability::Create,
481                ]),
482            ),
483        ]);
484        let guard = Guard::new(&project);
485        let id = ai_identity("ai:claude@joy", "dev@example.com");
486
487        // AI with Create -> CreateItem = Allow
488        assert_eq!(guard.check(&Action::CreateItem, &id), Verdict::Allow);
489
490        // AI with Implement -> ChangeStatus to InProgress = Allow
491        assert_eq!(
492            guard.check(
493                &Action::ChangeStatus {
494                    from: Status::Open,
495                    to: Status::InProgress
496                },
497                &id
498            ),
499            Verdict::Allow
500        );
501
502        // AI attempting Manage -> Deny (regardless of capabilities)
503        assert!(matches!(
504            guard.check(&Action::ManageProject, &id),
505            Verdict::Deny(_)
506        ));
507        assert!(matches!(
508            guard.check(&Action::ManageMilestone, &id),
509            Verdict::Deny(_)
510        ));
511        assert!(matches!(
512            guard.check(&Action::CreateRelease, &id),
513            Verdict::Deny(_)
514        ));
515    }
516
517    #[test]
518    fn gate_blocks_ai_on_configured_transition() {
519        let project = project_with_members(vec![
520            ("dev@example.com", MemberCapabilities::All),
521            (
522                "ai:claude@joy",
523                specific_caps(&[
524                    Capability::Implement,
525                    Capability::Review,
526                    Capability::Create,
527                ]),
528            ),
529        ]);
530        let mut gates = BTreeMap::new();
531        gates.insert(
532            "review -> closed".to_string(),
533            GateConfig { allow_ai: false },
534        );
535        let guard = Guard::with_gates(&project, gates);
536        let ai = ai_identity("ai:claude@joy", "dev@example.com");
537        let human = identity("dev@example.com");
538
539        // AI blocked by gate
540        assert!(matches!(
541            guard.check(
542                &Action::ChangeStatus {
543                    from: Status::Review,
544                    to: Status::Closed
545                },
546                &ai
547            ),
548            Verdict::Deny(_)
549        ));
550
551        // Human not blocked
552        assert_eq!(
553            guard.check(
554                &Action::ChangeStatus {
555                    from: Status::Review,
556                    to: Status::Closed
557                },
558                &human
559            ),
560            Verdict::Allow
561        );
562
563        // AI can still do other transitions
564        assert_eq!(
565            guard.check(
566                &Action::ChangeStatus {
567                    from: Status::InProgress,
568                    to: Status::Review
569                },
570                &ai
571            ),
572            Verdict::Allow
573        );
574    }
575
576    #[test]
577    fn no_gates_allows_all_transitions() {
578        let project = project_with_members(vec![(
579            "ai:claude@joy",
580            specific_caps(&[Capability::Review, Capability::Create]),
581        )]);
582        let guard = Guard::new(&project); // no gates
583        let ai = ai_identity("ai:claude@joy", "dev@example.com");
584
585        // Without gates, AI with Review can close
586        assert_eq!(
587            guard.check(
588                &Action::ChangeStatus {
589                    from: Status::Review,
590                    to: Status::Closed
591                },
592                &ai
593            ),
594            Verdict::Allow
595        );
596    }
597
598    #[test]
599    fn ai_member_can_close_without_gate() {
600        let project = project_with_members(vec![
601            ("dev@example.com", MemberCapabilities::All),
602            (
603                "ai:claude@joy",
604                specific_caps(&[
605                    Capability::Implement,
606                    Capability::Review,
607                    Capability::Create,
608                ]),
609            ),
610        ]);
611        let guard = Guard::new(&project);
612        let ai = ai_identity("ai:claude@joy", "dev@example.com");
613        let human = identity("dev@example.com");
614
615        // Without gate config, AI with Review capability CAN close items
616        assert_eq!(
617            guard.check(
618                &Action::ChangeStatus {
619                    from: Status::Review,
620                    to: Status::Closed
621                },
622                &ai
623            ),
624            Verdict::Allow
625        );
626
627        // AI can submit for review
628        assert_eq!(
629            guard.check(
630                &Action::ChangeStatus {
631                    from: Status::InProgress,
632                    to: Status::Review
633                },
634                &ai
635            ),
636            Verdict::Allow
637        );
638
639        // Human can close items
640        assert_eq!(
641            guard.check(
642                &Action::ChangeStatus {
643                    from: Status::Review,
644                    to: Status::Closed
645                },
646                &human
647            ),
648            Verdict::Allow
649        );
650    }
651
652    #[test]
653    fn unknown_member_denied() {
654        let project = project_with_members(vec![("dev@example.com", MemberCapabilities::All)]);
655        let guard = Guard::new(&project);
656        let id = identity("stranger@example.com");
657
658        assert!(matches!(
659            guard.check(&Action::CreateItem, &id),
660            Verdict::Deny(_)
661        ));
662    }
663
664    #[test]
665    fn status_transitions_require_correct_cap() {
666        let project = project_with_members(vec![(
667            "dev@example.com",
668            specific_caps(&[Capability::Implement, Capability::Create]),
669        )]);
670        let guard = Guard::new(&project);
671        let id = identity("dev@example.com");
672
673        let check_transition = |to: Status| -> Verdict {
674            guard.check(
675                &Action::ChangeStatus {
676                    from: Status::New,
677                    to,
678                },
679                &id,
680            )
681        };
682
683        // InProgress needs Implement -> Allow
684        assert_eq!(check_transition(Status::InProgress), Verdict::Allow);
685        // New needs Create -> Allow
686        assert_eq!(check_transition(Status::New), Verdict::Allow);
687        // Review needs Review -> Warn (missing)
688        assert!(matches!(check_transition(Status::Review), Verdict::Warn(_)));
689        // Closed needs Review -> Warn (missing)
690        assert!(matches!(check_transition(Status::Closed), Verdict::Warn(_)));
691        // Open needs Plan -> Warn (missing)
692        assert!(matches!(check_transition(Status::Open), Verdict::Warn(_)));
693        // Deferred needs Plan -> Warn (missing)
694        assert!(matches!(
695            check_transition(Status::Deferred),
696            Verdict::Warn(_)
697        ));
698    }
699
700    /// Integration test: realistic team with lead, developer, and AI agent.
701    /// Verifies the full gate enforcement across a typical workflow.
702    #[test]
703    fn team_workflow_gate_enforcement() {
704        let project = project_with_members(vec![
705            // Lead: full access
706            ("lead@example.com", MemberCapabilities::All),
707            // Developer: can implement, test, create, but not review or manage
708            (
709                "dev@example.com",
710                specific_caps(&[Capability::Implement, Capability::Test, Capability::Create]),
711            ),
712            // AI agent: can implement, review, create
713            (
714                "ai:claude@joy",
715                specific_caps(&[
716                    Capability::Implement,
717                    Capability::Review,
718                    Capability::Create,
719                ]),
720            ),
721        ]);
722        let guard = Guard::new(&project);
723
724        let lead = identity("lead@example.com");
725        let dev = identity("dev@example.com");
726        let ai = ai_identity("ai:claude@joy", "lead@example.com");
727
728        // === Creating items ===
729        // All three can create (all have Create)
730        assert_eq!(guard.check(&Action::CreateItem, &lead), Verdict::Allow);
731        assert_eq!(guard.check(&Action::CreateItem, &dev), Verdict::Allow);
732        assert_eq!(guard.check(&Action::CreateItem, &ai), Verdict::Allow);
733
734        // === Starting work (-> InProgress needs Implement) ===
735        let start = Action::ChangeStatus {
736            from: Status::Open,
737            to: Status::InProgress,
738        };
739        assert_eq!(guard.check(&start, &lead), Verdict::Allow);
740        assert_eq!(guard.check(&start, &dev), Verdict::Allow);
741        assert_eq!(guard.check(&start, &ai), Verdict::Allow);
742
743        // === Submitting for review (-> Review needs Review) ===
744        let submit = Action::ChangeStatus {
745            from: Status::InProgress,
746            to: Status::Review,
747        };
748        assert_eq!(guard.check(&submit, &lead), Verdict::Allow);
749        // Dev lacks Review -> Warn
750        assert!(matches!(guard.check(&submit, &dev), Verdict::Warn(_)));
751        // AI has Review -> Allow
752        assert_eq!(guard.check(&submit, &ai), Verdict::Allow);
753
754        // === Closing items (-> Closed needs Review + acceptance gate) ===
755        let close = Action::ChangeStatus {
756            from: Status::Review,
757            to: Status::Closed,
758        };
759        // Lead can close (capabilities: all)
760        assert_eq!(guard.check(&close, &lead), Verdict::Allow);
761        // Dev lacks Review -> Warn
762        assert!(matches!(guard.check(&close, &dev), Verdict::Warn(_)));
763        // Without gate config, AI with Review CAN close
764        assert_eq!(guard.check(&close, &ai), Verdict::Allow);
765
766        // === Managing project ===
767        // Lead can manage
768        assert_eq!(guard.check(&Action::ManageProject, &lead), Verdict::Allow);
769        // Dev lacks Manage -> Deny (management actions are hard-denied)
770        assert!(matches!(
771            guard.check(&Action::ManageProject, &dev),
772            Verdict::Deny(_)
773        ));
774        // AI cannot manage -> Deny
775        assert!(matches!(
776            guard.check(&Action::ManageProject, &ai),
777            Verdict::Deny(_)
778        ));
779    }
780
781    #[test]
782    fn required_capability_mapping_is_complete() {
783        // Verify every action maps to the expected capability
784        assert_eq!(Action::CreateItem.required_capability(), Capability::Create);
785        assert_eq!(Action::UpdateItem.required_capability(), Capability::Create);
786        assert_eq!(Action::DeleteItem.required_capability(), Capability::Delete);
787        assert_eq!(Action::AssignItem.required_capability(), Capability::Assign);
788        assert_eq!(Action::AddComment.required_capability(), Capability::Create);
789        assert_eq!(
790            Action::ManageProject.required_capability(),
791            Capability::Manage
792        );
793        assert_eq!(
794            Action::ManageMilestone.required_capability(),
795            Capability::Manage
796        );
797        assert_eq!(
798            Action::CreateRelease.required_capability(),
799            Capability::Manage
800        );
801
802        // Status transitions
803        let cs = |to: Status| Action::ChangeStatus {
804            from: Status::New,
805            to,
806        };
807        assert_eq!(
808            cs(Status::InProgress).required_capability(),
809            Capability::Implement
810        );
811        assert_eq!(cs(Status::Review).required_capability(), Capability::Review);
812        assert_eq!(cs(Status::Closed).required_capability(), Capability::Review);
813        assert_eq!(cs(Status::Deferred).required_capability(), Capability::Plan);
814        assert_eq!(cs(Status::Open).required_capability(), Capability::Plan);
815        assert_eq!(cs(Status::New).required_capability(), Capability::Create);
816
817        // StartJob delegates to its capability
818        assert_eq!(
819            Action::StartJob {
820                capability: Capability::Implement,
821                estimated_cost: None
822            }
823            .required_capability(),
824            Capability::Implement
825        );
826    }
827
828    #[test]
829    fn verdict_enforce_allow() {
830        let dir = tempfile::tempdir().unwrap();
831        setup_log_dir(dir.path());
832        let id = identity("dev@example.com");
833        assert!(Verdict::Allow.enforce(dir.path(), "TST-0001", &id).is_ok());
834    }
835
836    #[test]
837    fn verdict_enforce_deny_logs_event() {
838        let dir = tempfile::tempdir().unwrap();
839        setup_log_dir(dir.path());
840        let id = identity("dev@example.com");
841        let result = Verdict::Deny("blocked".into()).enforce(dir.path(), "TST-0001", &id);
842        assert!(result.is_err());
843        // Verify event was logged
844        let events = crate::event_log::read_events(dir.path(), None, None, 100).unwrap();
845        assert_eq!(events.len(), 1);
846        assert_eq!(events[0].event_type, "guard.denied");
847        assert_eq!(events[0].target, "TST-0001");
848        assert_eq!(events[0].details.as_deref(), Some("blocked"));
849    }
850
851    #[test]
852    fn verdict_enforce_warn_logs_event() {
853        let dir = tempfile::tempdir().unwrap();
854        setup_log_dir(dir.path());
855        let id = identity("dev@example.com");
856        let result = Verdict::Warn("caution".into()).enforce(dir.path(), "TST-0001", &id);
857        assert!(result.is_ok());
858        // Verify event was logged
859        let events = crate::event_log::read_events(dir.path(), None, None, 100).unwrap();
860        assert_eq!(events.len(), 1);
861        assert_eq!(events[0].event_type, "guard.warned");
862        assert_eq!(events[0].target, "TST-0001");
863        assert_eq!(events[0].details.as_deref(), Some("caution"));
864    }
865
866    #[test]
867    fn budget_precheck_allows_within_limit() {
868        let mut caps = BTreeMap::new();
869        caps.insert(
870            Capability::Implement,
871            crate::model::project::CapabilityConfig {
872                max_mode: None,
873                max_cost_per_job: Some(5.0),
874            },
875        );
876        let project =
877            project_with_members(vec![("ai:claude@joy", MemberCapabilities::Specific(caps))]);
878        let guard = Guard::new(&project);
879        let ai = ai_identity("ai:claude@joy", "dev@example.com");
880
881        // Within budget -> Allow
882        assert_eq!(
883            guard.check(
884                &Action::StartJob {
885                    capability: Capability::Implement,
886                    estimated_cost: Some(3.0),
887                },
888                &ai
889            ),
890            Verdict::Allow
891        );
892
893        // Exactly at limit -> Allow
894        assert_eq!(
895            guard.check(
896                &Action::StartJob {
897                    capability: Capability::Implement,
898                    estimated_cost: Some(5.0),
899                },
900                &ai
901            ),
902            Verdict::Allow
903        );
904    }
905
906    #[test]
907    fn budget_precheck_denies_over_limit() {
908        let mut caps = BTreeMap::new();
909        caps.insert(
910            Capability::Implement,
911            crate::model::project::CapabilityConfig {
912                max_mode: None,
913                max_cost_per_job: Some(5.0),
914            },
915        );
916        let project =
917            project_with_members(vec![("ai:claude@joy", MemberCapabilities::Specific(caps))]);
918        let guard = Guard::new(&project);
919        let ai = ai_identity("ai:claude@joy", "dev@example.com");
920
921        // Over budget -> Deny
922        let verdict = guard.check(
923            &Action::StartJob {
924                capability: Capability::Implement,
925                estimated_cost: Some(7.50),
926            },
927            &ai,
928        );
929        assert!(matches!(verdict, Verdict::Deny(_)));
930        if let Verdict::Deny(reason) = verdict {
931            assert!(reason.contains("7.50"));
932            assert!(reason.contains("5.00"));
933        }
934    }
935
936    #[test]
937    fn budget_precheck_allows_without_cost_limit() {
938        let project = project_with_members(vec![(
939            "ai:claude@joy",
940            specific_caps(&[Capability::Implement]),
941        )]);
942        let guard = Guard::new(&project);
943        let ai = ai_identity("ai:claude@joy", "dev@example.com");
944
945        // No max_cost_per_job configured -> Allow regardless of cost
946        assert_eq!(
947            guard.check(
948                &Action::StartJob {
949                    capability: Capability::Implement,
950                    estimated_cost: Some(999.0),
951                },
952                &ai
953            ),
954            Verdict::Allow
955        );
956    }
957
958    #[test]
959    fn budget_precheck_allows_without_estimate() {
960        let mut caps = BTreeMap::new();
961        caps.insert(
962            Capability::Implement,
963            crate::model::project::CapabilityConfig {
964                max_mode: None,
965                max_cost_per_job: Some(5.0),
966            },
967        );
968        let project =
969            project_with_members(vec![("ai:claude@joy", MemberCapabilities::Specific(caps))]);
970        let guard = Guard::new(&project);
971        let ai = ai_identity("ai:claude@joy", "dev@example.com");
972
973        // No estimated cost -> Allow (can't pre-check what we don't know)
974        assert_eq!(
975            guard.check(
976                &Action::StartJob {
977                    capability: Capability::Implement,
978                    estimated_cost: None,
979                },
980                &ai
981            ),
982            Verdict::Allow
983        );
984    }
985
986    #[test]
987    fn is_last_manager_solo() {
988        let project = project_with_members(vec![("lead@example.com", MemberCapabilities::All)]);
989        let guard = Guard::new(&project);
990        assert!(guard.is_last_manager("lead@example.com"));
991    }
992
993    #[test]
994    fn is_last_manager_with_backup() {
995        let project = project_with_members(vec![
996            ("lead@example.com", MemberCapabilities::All),
997            ("backup@example.com", MemberCapabilities::All),
998        ]);
999        let guard = Guard::new(&project);
1000        assert!(!guard.is_last_manager("lead@example.com"));
1001        assert!(!guard.is_last_manager("backup@example.com"));
1002    }
1003
1004    #[test]
1005    fn is_last_manager_ai_not_counted() {
1006        let project = project_with_members(vec![
1007            ("lead@example.com", MemberCapabilities::All),
1008            ("ai:claude@joy", MemberCapabilities::All),
1009        ]);
1010        let guard = Guard::new(&project);
1011        // AI members don't count as managers (Guard blocks AI from manage)
1012        assert!(guard.is_last_manager("lead@example.com"));
1013    }
1014
1015    #[test]
1016    fn is_last_manager_non_manager_member() {
1017        let project = project_with_members(vec![
1018            ("lead@example.com", MemberCapabilities::All),
1019            (
1020                "dev@example.com",
1021                specific_caps(&[Capability::Implement, Capability::Create]),
1022            ),
1023        ]);
1024        let guard = Guard::new(&project);
1025        // lead is the only manager
1026        assert!(guard.is_last_manager("lead@example.com"));
1027        // dev is not a manager
1028        assert!(!guard.is_last_manager("dev@example.com"));
1029    }
1030
1031    #[test]
1032    fn unauthenticated_denied_when_ai_members_exist() {
1033        let project = project_with_members(vec![
1034            ("dev@example.com", MemberCapabilities::All),
1035            (
1036                "ai:claude@joy",
1037                specific_caps(&[Capability::Implement, Capability::Create]),
1038            ),
1039        ]);
1040        let guard = Guard::new(&project);
1041        let unauth = unauthenticated_identity("dev@example.com");
1042
1043        // All write actions denied for unauthenticated members
1044        assert!(matches!(
1045            guard.check(&Action::CreateItem, &unauth),
1046            Verdict::Deny(_)
1047        ));
1048        assert!(matches!(
1049            guard.check(&Action::AddComment, &unauth),
1050            Verdict::Deny(_)
1051        ));
1052        assert!(matches!(
1053            guard.check(
1054                &Action::ChangeStatus {
1055                    from: Status::Open,
1056                    to: Status::InProgress
1057                },
1058                &unauth
1059            ),
1060            Verdict::Deny(_)
1061        ));
1062
1063        // Authenticated member is allowed
1064        let auth = identity("dev@example.com");
1065        assert_eq!(guard.check(&Action::CreateItem, &auth), Verdict::Allow);
1066    }
1067
1068    #[test]
1069    fn unauthenticated_allowed_without_ai_members() {
1070        let project = project_with_members(vec![("dev@example.com", MemberCapabilities::All)]);
1071        let guard = Guard::new(&project);
1072        let unauth = unauthenticated_identity("dev@example.com");
1073
1074        // No AI members -> unauthenticated access allowed (frictionless solo)
1075        assert_eq!(guard.check(&Action::CreateItem, &unauth), Verdict::Allow);
1076        assert_eq!(guard.check(&Action::AddComment, &unauth), Verdict::Allow);
1077    }
1078
1079    #[test]
1080    fn verdict_helpers() {
1081        assert!(Verdict::Allow.is_allowed());
1082        assert!(!Verdict::Allow.is_denied());
1083
1084        assert!(Verdict::Warn("w".into()).is_allowed());
1085        assert!(!Verdict::Warn("w".into()).is_denied());
1086
1087        assert!(!Verdict::Deny("d".into()).is_allowed());
1088        assert!(Verdict::Deny("d".into()).is_denied());
1089    }
1090}