Skip to main content

joy_core/model/
project.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4use std::collections::BTreeMap;
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use super::config::InteractionLevel;
10use super::item::Capability;
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct Project {
14    pub name: String,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub acronym: Option<String>,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub description: Option<String>,
19    #[serde(default = "default_language")]
20    pub language: String,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub forge: Option<String>,
23    #[serde(default, skip_serializing_if = "Docs::is_empty")]
24    pub docs: Docs,
25    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
26    pub members: BTreeMap<String, Member>,
27    /// Crypt zone registry. Empty / absent means encryption is not in
28    /// use; `crypt_wraps` on members and `crypt_zone` on items only have
29    /// meaning relative to the zones declared here. See ADR-038 and
30    /// vision/guardianship/Crypt.md.
31    #[serde(default, skip_serializing_if = "CryptConfig::is_empty")]
32    pub crypt: CryptConfig,
33    pub created: DateTime<Utc>,
34}
35
36/// Top-level Crypt configuration. Holds the zone registry; per-member
37/// wraps live on `Member.crypt_wraps`, per-item zone references live on
38/// `Item.crypt_zone`. The default zone uses the conventional name
39/// `"default"` and is auto-created on first `joy crypt add`.
40#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
41pub struct CryptConfig {
42    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
43    pub zones: BTreeMap<String, CryptZone>,
44}
45
46impl CryptConfig {
47    pub fn is_empty(&self) -> bool {
48        self.zones.is_empty()
49    }
50}
51
52/// A single Crypt zone: marked paths and project-wide properties. The
53/// zone key itself is never stored in plaintext; it lives only as
54/// per-member wraps under `Member.crypt_wraps[<zone-name>]` (humans) and
55/// per-(operator, AI) wraps under `delegations[<ai-member>][<operator>]`
56/// (AI Tool, ADR-041).
57#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
58pub struct CryptZone {
59    /// Path patterns (gitattributes-style globs) that belong to this
60    /// zone. Empty list means item-only encryption (zone references
61    /// come from items via `crypt_zone`).
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub paths: Vec<String>,
64    /// Per-(operator, AI) zone-key wraps for AI Tool delegations
65    /// (ADR-041 §3-4). Outer key is the AI member id (e.g.
66    /// `ai:claude@joy`); inner key is the operator email; value is the
67    /// hex-encoded X25519 wrap of the zone key against the operator's
68    /// stable delegation public key.
69    ///
70    /// One wrap per (operator, AI) pair, regardless of how many tokens
71    /// the operator has issued. Token issuance writes nothing here; the
72    /// embedded delegation private key in `--crypt` tokens is what the
73    /// AI uses to unwrap (ADR-041 §5).
74    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
75    pub delegations: BTreeMap<String, BTreeMap<String, String>>,
76}
77
78/// Configurable paths to the project's reference documentation, relative to
79/// the project root. Used by `joy ai init` to support existing repos with
80/// non-default doc layouts and read by AI tools via `joy project get docs.<key>`.
81#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
82pub struct Docs {
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub architecture: Option<String>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub vision: Option<String>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub contributing: Option<String>,
89}
90
91impl Docs {
92    pub const DEFAULT_ARCHITECTURE: &'static str = "docs/dev/architecture/README.md";
93    pub const DEFAULT_VISION: &'static str = "docs/dev/vision/README.md";
94    pub const DEFAULT_CONTRIBUTING: &'static str = "CONTRIBUTING.md";
95
96    pub fn is_empty(&self) -> bool {
97        self.architecture.is_none() && self.vision.is_none() && self.contributing.is_none()
98    }
99
100    /// Configured architecture path or the default if unset.
101    pub fn architecture_or_default(&self) -> &str {
102        self.architecture
103            .as_deref()
104            .unwrap_or(Self::DEFAULT_ARCHITECTURE)
105    }
106
107    /// Configured vision path or the default if unset.
108    pub fn vision_or_default(&self) -> &str {
109        self.vision.as_deref().unwrap_or(Self::DEFAULT_VISION)
110    }
111
112    /// Configured contributing path or the default if unset.
113    pub fn contributing_or_default(&self) -> &str {
114        self.contributing
115            .as_deref()
116            .unwrap_or(Self::DEFAULT_CONTRIBUTING)
117    }
118}
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct Member {
122    pub capabilities: MemberCapabilities,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub verify_key: Option<String>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub kdf_nonce: Option<String>,
127    /// AES-256-GCM ciphertext of the member's identity seed, encrypted
128    /// under a KEK derived from passphrase + kdf_nonce via Argon2id
129    /// (ADR-039). Hex-encoded `nonce || ciphertext || tag`.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub seed_wrap_passphrase: Option<String>,
132    /// AES-256-GCM ciphertext of the same seed, encrypted under a KEK
133    /// derived from a recovery key via Argon2id (ADR-039). The recovery
134    /// key itself is generated at `joy auth init`, displayed once, and
135    /// stored externally by the user. Hex-encoded.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub seed_wrap_recovery: Option<String>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub enrollment_verifier: Option<String>,
140    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
141    pub ai_delegations: BTreeMap<String, AiDelegationEntry>,
142    /// Per-member Crypt zone-key wraps. Map from zone name to the
143    /// hex-encoded `nonce || ciphertext || tag` produced by
144    /// `joy_crypt::wrap::wrap` over the zone key. The KEK derives from
145    /// the member's identity seed via HKDF-SHA256 with a fixed
146    /// "crypt-member-kek" tag.
147    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
148    pub crypt_wraps: BTreeMap<String, String>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub attestation: Option<Attestation>,
151}
152
153/// Per-member attestation: a signature by a manage member over a stable
154/// subset of the member's fields (email, capabilities, enrollment_verifier).
155/// Verified locally against project.yaml by looking up the attester's
156/// verify_key in the same file. The founder is the sole member allowed to
157/// have no attestation in a fresh project; once any additional manage
158/// member is added, that member implicitly reverse-attests the founder.
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160pub struct Attestation {
161    /// Email (or AI member id) of the member who produced the signature.
162    /// Must be a manage-capable member at signing time.
163    pub attester: String,
164    /// The fields this signature covers. verify_key is intentionally
165    /// excluded so that passphrase changes do not break existing
166    /// attestations.
167    pub signed_fields: AttestationSignedFields,
168    /// When the attestation was produced.
169    pub signed_at: chrono::DateTime<chrono::Utc>,
170    /// Hex-encoded Ed25519 signature over the canonical serialization of
171    /// `signed_fields`.
172    pub signature: String,
173}
174
175/// The exact subset of a member's state covered by the attestation
176/// signature. Changes to any of these fields invalidate the signature.
177///
178/// The serde key for `enrollment_verifier` is pinned to the historical
179/// name `otp_hash` (per ADR-035) so signatures created before the field
180/// rename remain bit-identically valid. Do not change the rename pin
181/// without coordinating an attestation re-signing pass.
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183pub struct AttestationSignedFields {
184    pub email: String,
185    pub capabilities: MemberCapabilities,
186    #[serde(default, rename = "otp_hash", skip_serializing_if = "Option::is_none")]
187    pub enrollment_verifier: Option<String>,
188}
189
190impl AttestationSignedFields {
191    /// Produce a deterministic byte sequence for signing/verification.
192    /// Stability relies on: (a) BTreeMap ordering in MemberCapabilities::Specific,
193    /// (b) struct field declaration order via serde_json, (c) skip-empty rules
194    /// being identical on write and read.
195    pub fn canonical_bytes(&self) -> Vec<u8> {
196        serde_json::to_vec(self).expect("AttestationSignedFields canonicalization")
197    }
198}
199
200/// A stable per-(human, AI) delegation key.
201///
202/// Under ADR-037 the delegation seed is deterministically derived from the
203/// human's Argon2id-derived identity material (`derive_key(passphrase, kdf_nonce)`)
204/// plus the per-(human, AI) `delegation_salt` recorded here. Identical inputs
205/// on any of the human's machines yield the same Ed25519 keypair, so the same
206/// delegation is reachable from anywhere without per-machine state in
207/// `project.yaml`. The matching private seed is cached at
208/// `~/.local/state/joy/delegations/<project>/<ai-member>.key` (0600); a missing
209/// cache is regenerated transparently from passphrase + salt at next use.
210///
211/// Legacy entries (created under ADR-033 §1, before ADR-037) carry no
212/// `delegation_salt`. They keep working on the machine whose local cache holds
213/// the matching random seed; rotating under the new code populates the salt
214/// and unblocks every other machine going forward.
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub struct AiDelegationEntry {
217    /// Public verifier of the stable delegation keypair (hex-encoded Ed25519).
218    /// Used to verify the binding signature on delegation tokens.
219    pub delegation_verifier: String,
220    /// 32-byte hex salt feeding HKDF-SHA256 over the human's identity material
221    /// (ADR-037). `None` for legacy entries created under ADR-033 §1; populated
222    /// by `joy ai rotate` and by every fresh delegation issued under ADR-037.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub delegation_salt: Option<String>,
225    /// When this delegation was first issued.
226    pub created: chrono::DateTime<chrono::Utc>,
227    /// When this delegation was last rotated, if ever.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub rotated: Option<chrono::DateTime<chrono::Utc>>,
230}
231
232#[derive(Debug, Clone, PartialEq)]
233pub enum MemberCapabilities {
234    All,
235    Specific(BTreeMap<Capability, CapabilityConfig>),
236}
237
238#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
239pub struct CapabilityConfig {
240    #[serde(rename = "max-mode", default, skip_serializing_if = "Option::is_none")]
241    pub max_mode: Option<InteractionLevel>,
242    #[serde(
243        rename = "max-cost-per-job",
244        default,
245        skip_serializing_if = "Option::is_none"
246    )]
247    pub max_cost_per_job: Option<f64>,
248}
249
250// ---------------------------------------------------------------------------
251// Mode defaults (from project.defaults.yaml, overridable in project.yaml)
252// ---------------------------------------------------------------------------
253
254/// Interaction mode defaults: a global default plus optional per-capability overrides.
255/// Deserializes from flat YAML like: `{ default: collaborative, implement: autonomous }`.
256#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
257pub struct ModeDefaults {
258    /// Fallback mode when no per-capability mode is set.
259    #[serde(default)]
260    pub default: InteractionLevel,
261    /// Per-capability mode overrides (flattened into the same map).
262    #[serde(flatten, default)]
263    pub capabilities: BTreeMap<Capability, InteractionLevel>,
264}
265
266/// Default capabilities granted to AI members by joy ai init.
267/// Loaded from `ai-defaults.capabilities` in project.defaults.yaml.
268#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
269pub struct AiDefaults {
270    #[serde(default, skip_serializing_if = "Vec::is_empty")]
271    pub capabilities: Vec<Capability>,
272}
273
274/// Source of a resolved interaction mode.
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum ModeSource {
277    /// From project.defaults.yaml (Joy's recommendation).
278    Default,
279    /// From project.yaml agents.defaults override.
280    Project,
281    /// From config.yaml personal preference.
282    Personal,
283    /// From item-level override (future).
284    Item,
285    /// Clamped by max-mode from project.yaml member config.
286    ProjectMax,
287}
288
289impl std::fmt::Display for ModeSource {
290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291        match self {
292            Self::Default => write!(f, "default"),
293            Self::Project => write!(f, "project"),
294            Self::Personal => write!(f, "personal"),
295            Self::Item => write!(f, "item"),
296            Self::ProjectMax => write!(f, "project max"),
297        }
298    }
299}
300
301/// Resolve the effective interaction mode for a given capability.
302///
303/// Resolution order (later wins):
304/// 1. Effective defaults global mode (project.defaults.yaml merged with project.yaml)
305/// 2. Effective defaults per-capability mode
306/// 3. Personal config preference
307///
308/// All clamped by max-mode from the member's CapabilityConfig.
309pub fn resolve_mode(
310    capability: &Capability,
311    raw_defaults: &ModeDefaults,
312    effective_defaults: &ModeDefaults,
313    personal_mode: Option<InteractionLevel>,
314    member_cap_config: Option<&CapabilityConfig>,
315) -> (InteractionLevel, ModeSource) {
316    // 1. Global fallback from effective defaults
317    let mut mode = effective_defaults.default;
318    let mut source = if effective_defaults.default != raw_defaults.default {
319        ModeSource::Project
320    } else {
321        ModeSource::Default
322    };
323
324    // 2. Per-capability default
325    if let Some(&cap_mode) = effective_defaults.capabilities.get(capability) {
326        mode = cap_mode;
327        let from_raw = raw_defaults.capabilities.get(capability) == Some(&cap_mode);
328        source = if from_raw {
329            ModeSource::Default
330        } else {
331            ModeSource::Project
332        };
333    }
334
335    // 3. Personal preference
336    if let Some(personal) = personal_mode {
337        mode = personal;
338        source = ModeSource::Personal;
339    }
340
341    // 4. Clamp by max-mode (minimum interactivity required)
342    if let Some(cap_config) = member_cap_config {
343        if let Some(max) = cap_config.max_mode {
344            if mode < max {
345                mode = max;
346                source = ModeSource::ProjectMax;
347            }
348        }
349    }
350
351    (mode, source)
352}
353
354// Custom serde for MemberCapabilities: "all" string or map of capabilities
355impl Serialize for MemberCapabilities {
356    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
357        match self {
358            MemberCapabilities::All => serializer.serialize_str("all"),
359            MemberCapabilities::Specific(map) => map.serialize(serializer),
360        }
361    }
362}
363
364impl<'de> Deserialize<'de> for MemberCapabilities {
365    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
366        let value = serde_yaml_ng::Value::deserialize(deserializer)?;
367        match &value {
368            serde_yaml_ng::Value::String(s) if s == "all" => Ok(MemberCapabilities::All),
369            serde_yaml_ng::Value::Mapping(_) => {
370                let map: BTreeMap<Capability, CapabilityConfig> =
371                    serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?;
372                Ok(MemberCapabilities::Specific(map))
373            }
374            _ => Err(serde::de::Error::custom(
375                "expected \"all\" or a map of capabilities",
376            )),
377        }
378    }
379}
380
381impl Member {
382    /// Create a member with the given capabilities and no auth fields.
383    pub fn new(capabilities: MemberCapabilities) -> Self {
384        Self {
385            capabilities,
386            verify_key: None,
387            kdf_nonce: None,
388            seed_wrap_passphrase: None,
389            seed_wrap_recovery: None,
390            enrollment_verifier: None,
391            ai_delegations: BTreeMap::new(),
392            crypt_wraps: BTreeMap::new(),
393            attestation: None,
394        }
395    }
396
397    /// Check whether this member has a specific capability.
398    pub fn has_capability(&self, cap: &Capability) -> bool {
399        match &self.capabilities {
400            MemberCapabilities::All => true,
401            MemberCapabilities::Specific(map) => map.contains_key(cap),
402        }
403    }
404}
405
406/// Check whether a member ID represents an AI member.
407pub fn is_ai_member(id: &str) -> bool {
408    id.starts_with("ai:")
409}
410
411fn default_language() -> String {
412    "en".to_string()
413}
414
415impl Project {
416    pub fn new(name: String, acronym: Option<String>) -> Self {
417        Self {
418            name,
419            acronym,
420            description: None,
421            language: default_language(),
422            forge: None,
423            docs: Docs::default(),
424            members: BTreeMap::new(),
425            crypt: CryptConfig::default(),
426            created: Utc::now(),
427        }
428    }
429}
430
431/// Validate and normalize a project acronym.
432///
433/// Acronyms drive item ID prefixes (`ACRONYM-XXXX`) and must therefore be
434/// ASCII, filesystem-safe, and short. Rules: ASCII uppercase letters (A-Z) or
435/// digits (0-9), length 2-8 after trimming. Input is trimmed and uppercased;
436/// the normalized form is returned on success so callers can store it as-is.
437pub fn validate_acronym(value: &str) -> Result<String, String> {
438    let normalized = value.trim().to_uppercase();
439    if normalized.len() < 2 || normalized.len() > 8 {
440        return Err(format!(
441            "acronym must be 2-8 characters, got {} ('{}')",
442            normalized.len(),
443            normalized
444        ));
445    }
446    for (i, c) in normalized.chars().enumerate() {
447        if !(c.is_ascii_uppercase() || c.is_ascii_digit()) {
448            return Err(format!(
449                "acronym character '{c}' at position {i} is not A-Z or 0-9"
450            ));
451        }
452    }
453    Ok(normalized)
454}
455
456/// Derive an acronym from a project name.
457/// Takes the first letter of each word, uppercase, max 4 characters.
458/// Single words use up to 3 uppercase characters.
459pub fn derive_acronym(name: &str) -> String {
460    let words: Vec<&str> = name.split_whitespace().collect();
461    if words.len() == 1 {
462        words[0]
463            .chars()
464            .filter(|c| c.is_alphanumeric())
465            .take(3)
466            .collect::<String>()
467            .to_uppercase()
468    } else {
469        words
470            .iter()
471            .filter_map(|w| w.chars().next())
472            .filter(|c| c.is_alphanumeric())
473            .take(4)
474            .collect::<String>()
475            .to_uppercase()
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn project_roundtrip() {
485        let project = Project::new("Test Project".into(), Some("TP".into()));
486        let yaml = serde_yaml_ng::to_string(&project).unwrap();
487        let parsed: Project = serde_yaml_ng::from_str(&yaml).unwrap();
488        assert_eq!(project, parsed);
489    }
490
491    // -----------------------------------------------------------------------
492    // ai_delegations (ADR-033) tests
493    // -----------------------------------------------------------------------
494
495    #[test]
496    fn ai_delegations_omitted_when_empty() {
497        let mut m = Member::new(MemberCapabilities::All);
498        assert!(m.ai_delegations.is_empty());
499        let yaml = serde_yaml_ng::to_string(&m).unwrap();
500        assert!(
501            !yaml.contains("ai_delegations"),
502            "empty ai_delegations should be skipped, got: {yaml}"
503        );
504        // sanity: round-trips empty
505        m.verify_key = Some("aa".repeat(32));
506        let yaml = serde_yaml_ng::to_string(&m).unwrap();
507        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
508        assert_eq!(m, parsed);
509    }
510
511    #[test]
512    fn ai_delegations_yaml_roundtrip() {
513        let mut m = Member::new(MemberCapabilities::All);
514        m.verify_key = Some("aa".repeat(32));
515        m.kdf_nonce = Some("bb".repeat(32));
516        m.ai_delegations.insert(
517            "ai:claude@joy".into(),
518            AiDelegationEntry {
519                delegation_verifier: "cc".repeat(32),
520                delegation_salt: None,
521                created: chrono::DateTime::parse_from_rfc3339("2026-04-15T10:00:00Z")
522                    .unwrap()
523                    .with_timezone(&chrono::Utc),
524                rotated: None,
525            },
526        );
527        let yaml = serde_yaml_ng::to_string(&m).unwrap();
528        assert!(yaml.contains("ai_delegations:"));
529        assert!(yaml.contains("ai:claude@joy:"));
530        assert!(yaml.contains("delegation_verifier:"));
531        assert!(
532            !yaml.contains("delegation_salt:"),
533            "unset delegation_salt should be skipped (legacy entry)"
534        );
535        assert!(
536            !yaml.contains("rotated:"),
537            "unset rotated should be skipped"
538        );
539
540        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
541        assert_eq!(m, parsed);
542    }
543
544    #[test]
545    fn ai_delegations_with_rotated_roundtrips() {
546        let mut m = Member::new(MemberCapabilities::All);
547        let created = chrono::DateTime::parse_from_rfc3339("2026-04-01T10:00:00Z")
548            .unwrap()
549            .with_timezone(&chrono::Utc);
550        let rotated = chrono::DateTime::parse_from_rfc3339("2026-04-15T12:30:00Z")
551            .unwrap()
552            .with_timezone(&chrono::Utc);
553        m.ai_delegations.insert(
554            "ai:claude@joy".into(),
555            AiDelegationEntry {
556                delegation_verifier: "dd".repeat(32),
557                delegation_salt: None,
558                created,
559                rotated: Some(rotated),
560            },
561        );
562        let yaml = serde_yaml_ng::to_string(&m).unwrap();
563        assert!(yaml.contains("rotated:"));
564        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
565        assert_eq!(m.ai_delegations["ai:claude@joy"].rotated, Some(rotated));
566        assert_eq!(parsed, m);
567    }
568
569    // -----------------------------------------------------------------------
570    // attestation (JOY-00FA-A5) tests
571    // -----------------------------------------------------------------------
572
573    #[test]
574    fn attestation_omitted_when_none() {
575        let m = Member::new(MemberCapabilities::All);
576        let yaml = serde_yaml_ng::to_string(&m).unwrap();
577        assert!(!yaml.contains("attestation:"));
578    }
579
580    #[test]
581    fn attestation_yaml_roundtrips() {
582        let mut m = Member::new(MemberCapabilities::All);
583        m.attestation = Some(Attestation {
584            attester: "horst@example.com".into(),
585            signed_fields: AttestationSignedFields {
586                email: "alice@example.com".into(),
587                capabilities: MemberCapabilities::All,
588                enrollment_verifier: Some("ff".repeat(32)),
589            },
590            signed_at: chrono::DateTime::parse_from_rfc3339("2026-04-20T10:00:00Z")
591                .unwrap()
592                .with_timezone(&chrono::Utc),
593            signature: "aa".repeat(32),
594        });
595        let yaml = serde_yaml_ng::to_string(&m).unwrap();
596        assert!(yaml.contains("attestation:"));
597        assert!(yaml.contains("attester: horst@example.com"));
598        let parsed: Member = serde_yaml_ng::from_str(&yaml).unwrap();
599        assert_eq!(parsed, m);
600    }
601
602    #[test]
603    fn attestation_signed_fields_canonical_is_deterministic() {
604        let a = AttestationSignedFields {
605            email: "alice@example.com".into(),
606            capabilities: MemberCapabilities::All,
607            enrollment_verifier: Some("abc".into()),
608        };
609        let b = a.clone();
610        assert_eq!(a.canonical_bytes(), b.canonical_bytes());
611    }
612
613    #[test]
614    fn attestation_signed_fields_differ_on_capability_change() {
615        let a = AttestationSignedFields {
616            email: "alice@example.com".into(),
617            capabilities: MemberCapabilities::All,
618            enrollment_verifier: None,
619        };
620        let mut caps = BTreeMap::new();
621        caps.insert(Capability::Implement, CapabilityConfig::default());
622        let b = AttestationSignedFields {
623            email: "alice@example.com".into(),
624            capabilities: MemberCapabilities::Specific(caps),
625            enrollment_verifier: None,
626        };
627        assert_ne!(a.canonical_bytes(), b.canonical_bytes());
628    }
629
630    #[test]
631    fn unknown_fields_from_legacy_yaml_are_ignored() {
632        // project.yaml files written by older Joy versions may still carry
633        // ai_tokens entries. They are silently discarded by serde default
634        // behaviour and do not block deserialisation.
635        let yaml = r#"
636capabilities: all
637public_key: aa
638salt: bb
639ai_tokens:
640  ai:claude@joy:
641    token_key: oldkey
642    created: "2026-03-28T22:00:00Z"
643ai_delegations:
644  ai:claude@joy:
645    delegation_verifier: newkey
646    created: "2026-04-15T10:00:00Z"
647"#;
648        let parsed: Member = serde_yaml_ng::from_str(yaml).unwrap();
649        assert_eq!(
650            parsed.ai_delegations["ai:claude@joy"].delegation_verifier,
651            "newkey"
652        );
653    }
654
655    // -----------------------------------------------------------------------
656    // Docs tests
657    // -----------------------------------------------------------------------
658
659    #[test]
660    fn docs_defaults_when_unset() {
661        let docs = Docs::default();
662        assert_eq!(docs.architecture_or_default(), Docs::DEFAULT_ARCHITECTURE);
663        assert_eq!(docs.vision_or_default(), Docs::DEFAULT_VISION);
664        assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
665    }
666
667    #[test]
668    fn docs_returns_configured_value() {
669        let docs = Docs {
670            architecture: Some("ARCHITECTURE.md".into()),
671            vision: Some("docs/product/vision.md".into()),
672            contributing: None,
673        };
674        assert_eq!(docs.architecture_or_default(), "ARCHITECTURE.md");
675        assert_eq!(docs.vision_or_default(), "docs/product/vision.md");
676        assert_eq!(docs.contributing_or_default(), Docs::DEFAULT_CONTRIBUTING);
677    }
678
679    #[test]
680    fn docs_omitted_from_yaml_when_empty() {
681        let project = Project::new("X".into(), None);
682        let yaml = serde_yaml_ng::to_string(&project).unwrap();
683        assert!(
684            !yaml.contains("docs:"),
685            "empty docs should be skipped, got: {yaml}"
686        );
687    }
688
689    #[test]
690    fn docs_present_in_yaml_when_set() {
691        let mut project = Project::new("X".into(), None);
692        project.docs.architecture = Some("ARCHITECTURE.md".into());
693        let yaml = serde_yaml_ng::to_string(&project).unwrap();
694        assert!(yaml.contains("docs:"), "docs block expected: {yaml}");
695        assert!(yaml.contains("architecture: ARCHITECTURE.md"));
696        assert!(!yaml.contains("vision:"), "unset fields should be skipped");
697    }
698
699    #[test]
700    fn docs_yaml_roundtrip_with_overrides() {
701        let yaml = r#"
702name: Existing
703language: en
704docs:
705  architecture: ARCHITECTURE.md
706  contributing: docs/CONTRIBUTING.md
707created: 2026-01-01T00:00:00Z
708"#;
709        let parsed: Project = serde_yaml_ng::from_str(yaml).unwrap();
710        assert_eq!(parsed.docs.architecture.as_deref(), Some("ARCHITECTURE.md"));
711        assert_eq!(parsed.docs.vision, None);
712        assert_eq!(
713            parsed.docs.contributing.as_deref(),
714            Some("docs/CONTRIBUTING.md")
715        );
716        assert_eq!(parsed.docs.vision_or_default(), Docs::DEFAULT_VISION);
717    }
718
719    #[test]
720    fn derive_acronym_multi_word() {
721        assert_eq!(derive_acronym("My Cool Project"), "MCP");
722    }
723
724    #[test]
725    fn derive_acronym_single_word() {
726        assert_eq!(derive_acronym("Joy"), "JOY");
727    }
728
729    #[test]
730    fn derive_acronym_long_name() {
731        assert_eq!(derive_acronym("A Very Long Project Name"), "AVLP");
732    }
733
734    #[test]
735    fn derive_acronym_single_long_word() {
736        assert_eq!(derive_acronym("Platform"), "PLA");
737    }
738
739    // -----------------------------------------------------------------------
740    // validate_acronym tests
741    // -----------------------------------------------------------------------
742
743    #[test]
744    fn validate_acronym_accepts_real_project_acronyms() {
745        for a in ["JI", "JOT", "JOY", "JON", "JP", "JAPP", "JOYC", "JISITE"] {
746            assert_eq!(validate_acronym(a).unwrap(), a, "rejected real acronym {a}");
747        }
748    }
749
750    #[test]
751    fn validate_acronym_accepts_alphanumeric() {
752        assert_eq!(validate_acronym("V2").unwrap(), "V2");
753        assert_eq!(validate_acronym("A1B2").unwrap(), "A1B2");
754    }
755
756    #[test]
757    fn validate_acronym_normalizes_case_and_whitespace() {
758        assert_eq!(validate_acronym("jyn").unwrap(), "JYN");
759        assert_eq!(validate_acronym("Jyn").unwrap(), "JYN");
760        assert_eq!(validate_acronym("  jyn  ").unwrap(), "JYN");
761    }
762
763    #[test]
764    fn validate_acronym_rejects_too_short() {
765        assert!(validate_acronym("").is_err());
766        assert!(validate_acronym("J").is_err());
767        assert!(validate_acronym(" J ").is_err());
768    }
769
770    #[test]
771    fn validate_acronym_rejects_too_long() {
772        assert!(validate_acronym("ABCDEFGHI").is_err());
773    }
774
775    #[test]
776    fn validate_acronym_rejects_non_alnum() {
777        assert!(validate_acronym("JY-N").is_err());
778        assert!(validate_acronym("JY N").is_err());
779        assert!(validate_acronym("JY_N").is_err());
780        assert!(validate_acronym("JY.N").is_err());
781    }
782
783    #[test]
784    fn validate_acronym_rejects_non_ascii() {
785        assert!(validate_acronym("AEBC").is_ok());
786        assert!(validate_acronym("ABC").is_ok());
787        assert!(validate_acronym("\u{00c4}BC").is_err());
788    }
789
790    // -----------------------------------------------------------------------
791    // ModeDefaults deserialization tests
792    // -----------------------------------------------------------------------
793
794    #[test]
795    fn mode_defaults_flat_yaml_roundtrip() {
796        let yaml = r#"
797default: interactive
798implement: collaborative
799review: pairing
800"#;
801        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
802        assert_eq!(parsed.default, InteractionLevel::Interactive);
803        assert_eq!(
804            parsed.capabilities[&Capability::Implement],
805            InteractionLevel::Collaborative
806        );
807        assert_eq!(
808            parsed.capabilities[&Capability::Review],
809            InteractionLevel::Pairing
810        );
811    }
812
813    #[test]
814    fn mode_defaults_empty_yaml() {
815        let yaml = "{}";
816        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
817        assert_eq!(parsed.default, InteractionLevel::Collaborative);
818        assert!(parsed.capabilities.is_empty());
819    }
820
821    #[test]
822    fn mode_defaults_only_default() {
823        let yaml = "default: pairing";
824        let parsed: ModeDefaults = serde_yaml_ng::from_str(yaml).unwrap();
825        assert_eq!(parsed.default, InteractionLevel::Pairing);
826        assert!(parsed.capabilities.is_empty());
827    }
828
829    #[test]
830    fn ai_defaults_yaml_roundtrip() {
831        let yaml = r#"
832capabilities:
833  - implement
834  - review
835"#;
836        let parsed: AiDefaults = serde_yaml_ng::from_str(yaml).unwrap();
837        assert_eq!(parsed.capabilities.len(), 2);
838        assert_eq!(parsed.capabilities[0], Capability::Implement);
839    }
840
841    // -----------------------------------------------------------------------
842    // resolve_mode tests
843    // -----------------------------------------------------------------------
844
845    fn defaults_with_mode(mode: InteractionLevel) -> ModeDefaults {
846        ModeDefaults {
847            default: mode,
848            ..Default::default()
849        }
850    }
851
852    fn defaults_with_cap_mode(cap: Capability, mode: InteractionLevel) -> ModeDefaults {
853        let mut d = ModeDefaults::default();
854        d.capabilities.insert(cap, mode);
855        d
856    }
857
858    #[test]
859    fn resolve_mode_uses_global_default() {
860        let raw = defaults_with_mode(InteractionLevel::Collaborative);
861        let effective = raw.clone();
862        let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
863        assert_eq!(mode, InteractionLevel::Collaborative);
864        assert_eq!(source, ModeSource::Default);
865    }
866
867    #[test]
868    fn resolve_mode_uses_per_capability_default() {
869        let raw = defaults_with_cap_mode(Capability::Review, InteractionLevel::Interactive);
870        let effective = raw.clone();
871        let (mode, source) = resolve_mode(&Capability::Review, &raw, &effective, None, None);
872        assert_eq!(mode, InteractionLevel::Interactive);
873        assert_eq!(source, ModeSource::Default);
874    }
875
876    #[test]
877    fn resolve_mode_project_override_detected() {
878        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
879        let effective =
880            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
881        let (mode, source) = resolve_mode(&Capability::Implement, &raw, &effective, None, None);
882        assert_eq!(mode, InteractionLevel::Interactive);
883        assert_eq!(source, ModeSource::Project);
884    }
885
886    #[test]
887    fn resolve_mode_personal_overrides_default() {
888        let raw = defaults_with_mode(InteractionLevel::Collaborative);
889        let effective = raw.clone();
890        let (mode, source) = resolve_mode(
891            &Capability::Implement,
892            &raw,
893            &effective,
894            Some(InteractionLevel::Pairing),
895            None,
896        );
897        assert_eq!(mode, InteractionLevel::Pairing);
898        assert_eq!(source, ModeSource::Personal);
899    }
900
901    #[test]
902    fn resolve_mode_max_mode_clamps_upward() {
903        let raw = defaults_with_mode(InteractionLevel::Autonomous);
904        let effective = raw.clone();
905        let cap_config = CapabilityConfig {
906            max_mode: Some(InteractionLevel::Supervised),
907            ..Default::default()
908        };
909        let (mode, source) = resolve_mode(
910            &Capability::Implement,
911            &raw,
912            &effective,
913            None,
914            Some(&cap_config),
915        );
916        assert_eq!(mode, InteractionLevel::Supervised);
917        assert_eq!(source, ModeSource::ProjectMax);
918    }
919
920    #[test]
921    fn resolve_mode_max_mode_does_not_lower() {
922        let raw = defaults_with_mode(InteractionLevel::Pairing);
923        let effective = raw.clone();
924        let cap_config = CapabilityConfig {
925            max_mode: Some(InteractionLevel::Supervised),
926            ..Default::default()
927        };
928        let (mode, source) = resolve_mode(
929            &Capability::Implement,
930            &raw,
931            &effective,
932            None,
933            Some(&cap_config),
934        );
935        // Pairing > Supervised, so no clamping
936        assert_eq!(mode, InteractionLevel::Pairing);
937        assert_eq!(source, ModeSource::Default);
938    }
939
940    #[test]
941    fn resolve_mode_personal_clamped_by_max() {
942        let raw = defaults_with_mode(InteractionLevel::Collaborative);
943        let effective = raw.clone();
944        let cap_config = CapabilityConfig {
945            max_mode: Some(InteractionLevel::Interactive),
946            ..Default::default()
947        };
948        let (mode, source) = resolve_mode(
949            &Capability::Implement,
950            &raw,
951            &effective,
952            Some(InteractionLevel::Autonomous),
953            Some(&cap_config),
954        );
955        // Personal is Autonomous but max is Interactive, clamp up
956        assert_eq!(mode, InteractionLevel::Interactive);
957        assert_eq!(source, ModeSource::ProjectMax);
958    }
959
960    // -----------------------------------------------------------------------
961    // Item mode serialization
962    // -----------------------------------------------------------------------
963
964    #[test]
965    fn item_mode_field_roundtrip() {
966        use crate::model::item::{Item, ItemType, Priority};
967
968        let mut item = Item::new(
969            "TST-0001".into(),
970            "Test".into(),
971            ItemType::Task,
972            Priority::Medium,
973            vec![],
974        );
975        item.mode = Some(InteractionLevel::Pairing);
976
977        let yaml = serde_yaml_ng::to_string(&item).unwrap();
978        assert!(yaml.contains("mode: pairing"), "mode field not serialized");
979
980        let parsed: Item = serde_yaml_ng::from_str(&yaml).unwrap();
981        assert_eq!(parsed.mode, Some(InteractionLevel::Pairing));
982    }
983
984    #[test]
985    fn item_mode_field_absent_when_none() {
986        use crate::model::item::{Item, ItemType, Priority};
987
988        let item = Item::new(
989            "TST-0002".into(),
990            "Test".into(),
991            ItemType::Task,
992            Priority::Medium,
993            vec![],
994        );
995        assert_eq!(item.mode, None);
996
997        let yaml = serde_yaml_ng::to_string(&item).unwrap();
998        assert!(
999            !yaml.contains("mode:"),
1000            "mode field should not appear when None"
1001        );
1002    }
1003
1004    #[test]
1005    fn item_mode_deserialized_from_existing_yaml() {
1006        let yaml = r#"
1007id: TST-0003
1008title: Test
1009type: task
1010status: new
1011priority: medium
1012mode: interactive
1013created: "2026-01-01T00:00:00+00:00"
1014updated: "2026-01-01T00:00:00+00:00"
1015"#;
1016        let item: crate::model::item::Item = serde_yaml_ng::from_str(yaml).unwrap();
1017        assert_eq!(item.mode, Some(InteractionLevel::Interactive));
1018    }
1019
1020    // -----------------------------------------------------------------------
1021    // Full four-layer resolution scenario
1022    // -----------------------------------------------------------------------
1023
1024    #[test]
1025    fn resolve_mode_full_scenario() {
1026        // Joy default: implement = collaborative
1027        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
1028        // Project override: implement = interactive
1029        let effective =
1030            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
1031        // Personal preference: autonomous
1032        let personal = Some(InteractionLevel::Autonomous);
1033        // Project max-mode: supervised (minimum interactivity)
1034        let cap_config = CapabilityConfig {
1035            max_mode: Some(InteractionLevel::Supervised),
1036            ..Default::default()
1037        };
1038
1039        let (mode, source) = resolve_mode(
1040            &Capability::Implement,
1041            &raw,
1042            &effective,
1043            personal,
1044            Some(&cap_config),
1045        );
1046
1047        // Personal (autonomous) < max (supervised), so clamped up to supervised
1048        assert_eq!(mode, InteractionLevel::Supervised);
1049        assert_eq!(source, ModeSource::ProjectMax);
1050    }
1051
1052    #[test]
1053    fn resolve_mode_all_layers_no_clamping() {
1054        // Joy default: implement = collaborative
1055        let raw = defaults_with_cap_mode(Capability::Implement, InteractionLevel::Collaborative);
1056        // Project override: implement = interactive
1057        let effective =
1058            defaults_with_cap_mode(Capability::Implement, InteractionLevel::Interactive);
1059        // Personal preference: pairing (more interactive than project)
1060        let personal = Some(InteractionLevel::Pairing);
1061        // No max-mode
1062        let cap_config = CapabilityConfig::default();
1063
1064        let (mode, source) = resolve_mode(
1065            &Capability::Implement,
1066            &raw,
1067            &effective,
1068            personal,
1069            Some(&cap_config),
1070        );
1071
1072        // Personal wins, no clamping
1073        assert_eq!(mode, InteractionLevel::Pairing);
1074        assert_eq!(source, ModeSource::Personal);
1075    }
1076}