Skip to main content

exo_avc/
credential.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Core AVC types: credential, draft, intent, scope, constraints, refs.
18//!
19//! All vectors are normalized (sorted and deduplicated) before any signing
20//! or hashing operation so that two callers constructing the same logical
21//! credential always produce the same bytes and the same ID.
22
23use std::collections::BTreeSet;
24
25use exo_authority::permission::Permission;
26use exo_core::{Did, Hash256, Signature, Timestamp, hash::hash_structured};
27use serde::{Deserialize, Serialize};
28
29use crate::error::AvcError;
30
31/// Signing domain tag for AVC credentials.
32pub const AVC_CREDENTIAL_SIGNING_DOMAIN: &str = "exo.avc.credential.v1";
33/// Schema version supported by this binary.
34pub const AVC_SCHEMA_VERSION: u16 = 1;
35/// Current AVC wire protocol version exposed by node and WASM adapters.
36pub const AVC_PROTOCOL_VERSION: u16 = 1;
37/// Oldest AVC wire protocol version this binary accepts.
38pub const AVC_MIN_SUPPORTED_PROTOCOL_VERSION: u16 = 1;
39/// Newest AVC wire protocol version this binary accepts.
40pub const AVC_MAX_SUPPORTED_PROTOCOL_VERSION: u16 = AVC_PROTOCOL_VERSION;
41/// Compatibility runway advertised to clients before an AVC protocol removal.
42pub const AVC_PROTOCOL_DEPRECATION_WINDOW_DAYS: u16 = 180;
43/// Maximum value (in basis points) that any AVC bp field may hold.
44pub const MAX_BASIS_POINTS: u32 = 10_000;
45
46/// Normalize an optional caller protocol version and reject unsupported ranges.
47///
48/// Missing protocol metadata is treated as legacy current-v1 traffic so
49/// existing AVC callers remain compatible while new clients can probe the
50/// explicit supported range through node/WASM discovery.
51pub fn require_supported_avc_protocol_version(
52    requested_protocol_version: Option<u16>,
53) -> Result<u16, AvcError> {
54    let got = requested_protocol_version.unwrap_or(AVC_PROTOCOL_VERSION);
55    if !(AVC_MIN_SUPPORTED_PROTOCOL_VERSION..=AVC_MAX_SUPPORTED_PROTOCOL_VERSION).contains(&got) {
56        return Err(AvcError::UnsupportedProtocol {
57            got,
58            min_supported: AVC_MIN_SUPPORTED_PROTOCOL_VERSION,
59            max_supported: AVC_MAX_SUPPORTED_PROTOCOL_VERSION,
60        });
61    }
62    Ok(got)
63}
64
65// ---------------------------------------------------------------------------
66// Subject kind
67// ---------------------------------------------------------------------------
68
69/// What kind of autonomous actor the AVC describes.
70///
71/// AVC subjects are not limited to AI agents — workflows, services,
72/// holons, and organizational units can all hold credentials.
73#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
74pub enum AvcSubjectKind {
75    /// An AI agent identified by model ID (and optional version).
76    AiAgent {
77        model_id: String,
78        agent_version: Option<String>,
79    },
80    /// A swarm or collective of AI agents.
81    AgentSwarm { swarm_id: String },
82    /// A deterministic workflow.
83    Workflow { workflow_id: String },
84    /// A long-running service.
85    Service { service_id: String },
86    /// A holon — a self-contained constitutional automation.
87    Holon { holon_id: String },
88    /// A unit of an organization (department, team, fund).
89    OrganizationUnit { unit_id: String },
90    /// Subject kind is not yet specified.
91    Unknown,
92}
93
94impl AvcSubjectKind {
95    fn validate(&self) -> Result<(), AvcError> {
96        match self {
97            Self::AiAgent { model_id, .. } => non_empty(model_id, "subject_kind.model_id"),
98            Self::AgentSwarm { swarm_id } => non_empty(swarm_id, "subject_kind.swarm_id"),
99            Self::Workflow { workflow_id } => non_empty(workflow_id, "subject_kind.workflow_id"),
100            Self::Service { service_id } => non_empty(service_id, "subject_kind.service_id"),
101            Self::Holon { holon_id } => non_empty(holon_id, "subject_kind.holon_id"),
102            Self::OrganizationUnit { unit_id } => non_empty(unit_id, "subject_kind.unit_id"),
103            Self::Unknown => Ok(()),
104        }
105    }
106}
107
108// ---------------------------------------------------------------------------
109// Autonomy level
110// ---------------------------------------------------------------------------
111
112/// Bounded integer ladder describing how autonomous the actor may be.
113///
114/// Repr is `u8` so it is canonical in CBOR and orderable as a small integer.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
116#[repr(u8)]
117pub enum AutonomyLevel {
118    ObserveOnly = 0,
119    Recommend = 1,
120    Draft = 2,
121    ExecuteWithHumanApproval = 3,
122    ExecuteWithinBounds = 4,
123    DelegateWithinBounds = 5,
124}
125
126// ---------------------------------------------------------------------------
127// Delegated intent
128// ---------------------------------------------------------------------------
129
130/// What the autonomous actor is being delegated to pursue.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct DelegatedIntent {
133    /// Stable hash identifying the intent body.
134    pub intent_id: Hash256,
135    /// Plain-language purpose, included in the signed payload.
136    pub purpose: String,
137    /// Allowed objectives — sorted/deduped before signing.
138    pub allowed_objectives: Vec<String>,
139    /// Prohibited objectives — sorted/deduped before signing.
140    pub prohibited_objectives: Vec<String>,
141    /// Maximum autonomy permitted under this credential.
142    pub autonomy_level: AutonomyLevel,
143    /// Whether the holder may delegate a narrower AVC.
144    pub delegation_allowed: bool,
145}
146
147impl DelegatedIntent {
148    fn validate(&self) -> Result<(), AvcError> {
149        non_empty(&self.purpose, "delegated_intent.purpose")?;
150        for obj in &self.allowed_objectives {
151            non_empty(obj, "delegated_intent.allowed_objectives")?;
152        }
153        for obj in &self.prohibited_objectives {
154            non_empty(obj, "delegated_intent.prohibited_objectives")?;
155        }
156        Ok(())
157    }
158
159    fn normalize(&mut self) {
160        self.allowed_objectives = sort_dedup(self.allowed_objectives.drain(..));
161        self.prohibited_objectives = sort_dedup(self.prohibited_objectives.drain(..));
162    }
163}
164
165// ---------------------------------------------------------------------------
166// Data class
167// ---------------------------------------------------------------------------
168
169#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
170pub enum DataClass {
171    Public,
172    Internal,
173    Confidential,
174    Restricted,
175    PersonalData,
176    SensitivePersonalData,
177    Financial,
178    LegalPrivileged,
179    Custom(String),
180}
181
182impl DataClass {
183    fn validate(&self) -> Result<(), AvcError> {
184        if let Self::Custom(name) = self {
185            non_empty(name, "data_class.custom")?;
186        }
187        Ok(())
188    }
189}
190
191// ---------------------------------------------------------------------------
192// Authority scope
193// ---------------------------------------------------------------------------
194
195/// The set of permissions, tools, data classes, counterparties, and
196/// jurisdictions that the credential authorizes the actor to use.
197///
198/// All vectors are normalized (sorted, deduplicated) before signing so
199/// caller ordering does not affect the credential ID.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct AuthorityScope {
202    pub permissions: Vec<Permission>,
203    pub tools: Vec<String>,
204    pub data_classes: Vec<DataClass>,
205    pub counterparties: Vec<Did>,
206    pub jurisdictions: Vec<String>,
207}
208
209impl AuthorityScope {
210    /// An empty scope — convenient for narrow delegation drafts.
211    #[must_use]
212    pub fn empty() -> Self {
213        Self {
214            permissions: Vec::new(),
215            tools: Vec::new(),
216            data_classes: Vec::new(),
217            counterparties: Vec::new(),
218            jurisdictions: Vec::new(),
219        }
220    }
221
222    fn validate(&self) -> Result<(), AvcError> {
223        for tool in &self.tools {
224            non_empty(tool, "authority_scope.tools")?;
225        }
226        for class in &self.data_classes {
227            class.validate()?;
228        }
229        for jurisdiction in &self.jurisdictions {
230            non_empty(jurisdiction, "authority_scope.jurisdictions")?;
231        }
232        Ok(())
233    }
234
235    fn normalize(&mut self) {
236        self.permissions = sort_dedup_copy(self.permissions.iter().copied());
237        self.tools = sort_dedup(self.tools.drain(..));
238        self.data_classes = sort_dedup(self.data_classes.drain(..));
239        let mut cp: Vec<Did> = self.counterparties.drain(..).collect();
240        cp.sort();
241        cp.dedup();
242        self.counterparties = cp;
243        self.jurisdictions = sort_dedup(self.jurisdictions.drain(..));
244    }
245}
246
247// ---------------------------------------------------------------------------
248// Time window
249// ---------------------------------------------------------------------------
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct TimeWindow {
253    pub not_before: Timestamp,
254    pub not_after: Timestamp,
255}
256
257impl TimeWindow {
258    fn validate(&self) -> Result<(), AvcError> {
259        if self.not_after <= self.not_before {
260            return Err(AvcError::InvalidTimestamp {
261                reason: "time_window.not_after must be strictly after not_before".into(),
262            });
263        }
264        Ok(())
265    }
266
267    /// Returns true when `now` is within `[not_before, not_after]`.
268    #[must_use]
269    pub fn contains(&self, now: &Timestamp) -> bool {
270        now >= &self.not_before && now <= &self.not_after
271    }
272}
273
274// ---------------------------------------------------------------------------
275// Constraints
276// ---------------------------------------------------------------------------
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct AvcConstraints {
280    pub max_budget_minor_units: Option<u64>,
281    pub currency_code: Option<String>,
282    pub max_action_risk_bp: Option<u32>,
283    pub human_approval_required: bool,
284    pub approval_threshold_bp: Option<u32>,
285    pub max_delegation_depth: u32,
286    pub allowed_time_window: Option<TimeWindow>,
287    pub forbidden_actions: Vec<String>,
288    pub emergency_stop_refs: Vec<String>,
289}
290
291impl AvcConstraints {
292    /// A permissive default — useful as a baseline for tests and demos.
293    #[must_use]
294    pub fn permissive() -> Self {
295        Self {
296            max_budget_minor_units: None,
297            currency_code: None,
298            max_action_risk_bp: None,
299            human_approval_required: false,
300            approval_threshold_bp: None,
301            max_delegation_depth: 0,
302            allowed_time_window: None,
303            forbidden_actions: Vec::new(),
304            emergency_stop_refs: Vec::new(),
305        }
306    }
307
308    fn validate(&self) -> Result<(), AvcError> {
309        if let Some(value) = self.max_action_risk_bp {
310            require_bp("max_action_risk_bp", value)?;
311        }
312        if let Some(value) = self.approval_threshold_bp {
313            require_bp("approval_threshold_bp", value)?;
314        }
315        if let Some(window) = &self.allowed_time_window {
316            window.validate()?;
317        }
318        if let Some(currency) = &self.currency_code {
319            non_empty(currency, "constraints.currency_code")?;
320        }
321        for action in &self.forbidden_actions {
322            non_empty(action, "constraints.forbidden_actions")?;
323        }
324        for stop in &self.emergency_stop_refs {
325            non_empty(stop, "constraints.emergency_stop_refs")?;
326        }
327        Ok(())
328    }
329
330    fn normalize(&mut self) {
331        self.forbidden_actions = sort_dedup(self.forbidden_actions.drain(..));
332        self.emergency_stop_refs = sort_dedup(self.emergency_stop_refs.drain(..));
333    }
334}
335
336// ---------------------------------------------------------------------------
337// Consent / Policy refs
338// ---------------------------------------------------------------------------
339
340#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
341pub struct ConsentRef {
342    pub consent_id: Hash256,
343    pub required: bool,
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
347pub struct PolicyRef {
348    pub policy_id: Hash256,
349    pub policy_version: u16,
350    pub required: bool,
351}
352
353// ---------------------------------------------------------------------------
354// Authority chain wrapper
355// ---------------------------------------------------------------------------
356
357/// Hash of an authority chain whose verification is delegated to
358/// `exo-authority` at validation time. The chain itself is held by the
359/// validator's registry; the credential carries only its hash so the
360/// signed AVC payload remains compact and cannot replay private chain
361/// content.
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363pub struct AuthorityChainRef {
364    pub chain_hash: Hash256,
365}
366
367// ---------------------------------------------------------------------------
368// Credential — signed
369// ---------------------------------------------------------------------------
370
371/// A portable, signed, machine-verifiable Autonomous Volition Credential.
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373pub struct AutonomousVolitionCredential {
374    pub schema_version: u16,
375    pub issuer_did: Did,
376    pub principal_did: Did,
377    pub subject_did: Did,
378    pub holder_did: Option<Did>,
379    pub subject_kind: AvcSubjectKind,
380    pub created_at: Timestamp,
381    pub expires_at: Option<Timestamp>,
382    pub delegated_intent: DelegatedIntent,
383    pub authority_scope: AuthorityScope,
384    pub constraints: AvcConstraints,
385    pub authority_chain: Option<AuthorityChainRef>,
386    pub consent_refs: Vec<ConsentRef>,
387    pub policy_refs: Vec<PolicyRef>,
388    pub parent_avc_id: Option<Hash256>,
389    pub signature: Signature,
390}
391
392/// A credential draft — every field of the credential except `signature`.
393#[derive(Debug, Clone, PartialEq, Eq)]
394pub struct AvcDraft {
395    pub schema_version: u16,
396    pub issuer_did: Did,
397    pub principal_did: Did,
398    pub subject_did: Did,
399    pub holder_did: Option<Did>,
400    pub subject_kind: AvcSubjectKind,
401    pub created_at: Timestamp,
402    pub expires_at: Option<Timestamp>,
403    pub delegated_intent: DelegatedIntent,
404    pub authority_scope: AuthorityScope,
405    pub constraints: AvcConstraints,
406    pub authority_chain: Option<AuthorityChainRef>,
407    pub consent_refs: Vec<ConsentRef>,
408    pub policy_refs: Vec<PolicyRef>,
409    pub parent_avc_id: Option<Hash256>,
410}
411
412impl AvcDraft {
413    /// Normalize all collections deterministically and validate every
414    /// structural rule. This is invoked from `issue_avc`/`delegate_avc`
415    /// before signing so the signed payload is always canonical.
416    ///
417    /// # Errors
418    /// Returns [`AvcError`] for any structural violation.
419    pub fn normalize_and_validate(&mut self) -> Result<(), AvcError> {
420        if self.schema_version != AVC_SCHEMA_VERSION {
421            return Err(AvcError::UnsupportedSchema {
422                got: self.schema_version,
423                supported: AVC_SCHEMA_VERSION,
424            });
425        }
426        self.subject_kind.validate()?;
427        self.delegated_intent.validate()?;
428        self.delegated_intent.normalize();
429        self.authority_scope.validate()?;
430        self.authority_scope.normalize();
431        self.constraints.validate()?;
432        self.constraints.normalize();
433
434        if let Some(expires) = self.expires_at {
435            if expires <= self.created_at {
436                return Err(AvcError::InvalidTimestamp {
437                    reason: "expires_at must be strictly after created_at".into(),
438                });
439            }
440        }
441
442        self.consent_refs.sort();
443        self.consent_refs.dedup();
444        self.policy_refs.sort();
445        self.policy_refs.dedup();
446
447        Ok(())
448    }
449}
450
451#[derive(Serialize)]
452struct AvcSigningPayload<'a> {
453    domain: &'static str,
454    schema_version: u16,
455    issuer_did: &'a Did,
456    principal_did: &'a Did,
457    subject_did: &'a Did,
458    holder_did: Option<&'a Did>,
459    subject_kind: &'a AvcSubjectKind,
460    created_at: &'a Timestamp,
461    expires_at: Option<&'a Timestamp>,
462    delegated_intent: &'a DelegatedIntent,
463    authority_scope: &'a AuthorityScope,
464    constraints: &'a AvcConstraints,
465    authority_chain: Option<&'a AuthorityChainRef>,
466    consent_refs: &'a [ConsentRef],
467    policy_refs: &'a [PolicyRef],
468    parent_avc_id: Option<&'a Hash256>,
469}
470
471impl AutonomousVolitionCredential {
472    /// Compute the canonical signing payload bytes for this credential.
473    ///
474    /// The payload is domain-separated CBOR over every field _except_
475    /// `signature`. Tampering with any signed field yields a different
476    /// payload and therefore a different signature/ID.
477    ///
478    /// # Errors
479    /// Returns [`AvcError::Serialization`] when CBOR encoding fails.
480    pub fn signing_payload(&self) -> Result<Vec<u8>, AvcError> {
481        let payload = AvcSigningPayload {
482            domain: AVC_CREDENTIAL_SIGNING_DOMAIN,
483            schema_version: self.schema_version,
484            issuer_did: &self.issuer_did,
485            principal_did: &self.principal_did,
486            subject_did: &self.subject_did,
487            holder_did: self.holder_did.as_ref(),
488            subject_kind: &self.subject_kind,
489            created_at: &self.created_at,
490            expires_at: self.expires_at.as_ref(),
491            delegated_intent: &self.delegated_intent,
492            authority_scope: &self.authority_scope,
493            constraints: &self.constraints,
494            authority_chain: self.authority_chain.as_ref(),
495            consent_refs: &self.consent_refs,
496            policy_refs: &self.policy_refs,
497            parent_avc_id: self.parent_avc_id.as_ref(),
498        };
499        let mut buf = Vec::new();
500        ciborium::ser::into_writer(&payload, &mut buf)?;
501        Ok(buf)
502    }
503
504    /// Deterministic content-addressed identifier for the credential.
505    ///
506    /// `id = blake3(canonical_cbor(signing_payload))` — independent of
507    /// caller insertion order or runtime memory layout.
508    ///
509    /// # Errors
510    /// Returns [`AvcError::Serialization`] when CBOR encoding fails.
511    pub fn id(&self) -> Result<Hash256, AvcError> {
512        Ok(Hash256::digest(&self.signing_payload()?))
513    }
514
515    /// Compute the same ID a credential _would have_ if its `signature`
516    /// changed but every other field stayed the same. Equivalent to
517    /// [`Self::id`] because `signature` is excluded from the payload.
518    ///
519    /// # Errors
520    /// Returns [`AvcError::Serialization`] when CBOR encoding fails.
521    pub fn content_hash(&self) -> Result<Hash256, AvcError> {
522        hash_structured(&AvcSigningPayload {
523            domain: AVC_CREDENTIAL_SIGNING_DOMAIN,
524            schema_version: self.schema_version,
525            issuer_did: &self.issuer_did,
526            principal_did: &self.principal_did,
527            subject_did: &self.subject_did,
528            holder_did: self.holder_did.as_ref(),
529            subject_kind: &self.subject_kind,
530            created_at: &self.created_at,
531            expires_at: self.expires_at.as_ref(),
532            delegated_intent: &self.delegated_intent,
533            authority_scope: &self.authority_scope,
534            constraints: &self.constraints,
535            authority_chain: self.authority_chain.as_ref(),
536            consent_refs: &self.consent_refs,
537            policy_refs: &self.policy_refs,
538            parent_avc_id: self.parent_avc_id.as_ref(),
539        })
540        .map_err(AvcError::from)
541    }
542
543    /// Returns the effective holder DID, defaulting to `subject_did`
544    /// when `holder_did` is absent.
545    #[must_use]
546    pub fn effective_holder(&self) -> &Did {
547        self.holder_did.as_ref().unwrap_or(&self.subject_did)
548    }
549}
550
551/// Issue a signed AVC from a draft.
552///
553/// The draft is normalized and validated, the canonical signing payload
554/// is computed, and the supplied `sign` closure is invoked exactly once
555/// over those bytes. The resulting credential's ID is content-addressed
556/// over the signing payload (excluding the signature).
557///
558/// # Errors
559/// Returns [`AvcError`] if the draft is structurally invalid or CBOR
560/// encoding fails.
561pub fn issue_avc<F>(mut draft: AvcDraft, sign: F) -> Result<AutonomousVolitionCredential, AvcError>
562where
563    F: FnOnce(&[u8]) -> Signature,
564{
565    draft.normalize_and_validate()?;
566
567    let mut credential = AutonomousVolitionCredential {
568        schema_version: draft.schema_version,
569        issuer_did: draft.issuer_did,
570        principal_did: draft.principal_did,
571        subject_did: draft.subject_did,
572        holder_did: draft.holder_did,
573        subject_kind: draft.subject_kind,
574        created_at: draft.created_at,
575        expires_at: draft.expires_at,
576        delegated_intent: draft.delegated_intent,
577        authority_scope: draft.authority_scope,
578        constraints: draft.constraints,
579        authority_chain: draft.authority_chain,
580        consent_refs: draft.consent_refs,
581        policy_refs: draft.policy_refs,
582        parent_avc_id: draft.parent_avc_id,
583        signature: Signature::empty(),
584    };
585
586    let payload = credential.signing_payload()?;
587    credential.signature = sign(&payload);
588    Ok(credential)
589}
590
591// ---------------------------------------------------------------------------
592// Helpers
593// ---------------------------------------------------------------------------
594
595fn non_empty(value: &str, field: &'static str) -> Result<(), AvcError> {
596    if value.trim().is_empty() {
597        Err(AvcError::EmptyField { field })
598    } else {
599        Ok(())
600    }
601}
602
603fn require_bp(field: &'static str, value: u32) -> Result<(), AvcError> {
604    if value > MAX_BASIS_POINTS {
605        Err(AvcError::BasisPointOutOfRange { field, value })
606    } else {
607        Ok(())
608    }
609}
610
611fn sort_dedup<T: Ord, I: IntoIterator<Item = T>>(items: I) -> Vec<T> {
612    let set: BTreeSet<T> = items.into_iter().collect();
613    set.into_iter().collect()
614}
615
616fn sort_dedup_copy<T: Ord + Copy, I: IntoIterator<Item = T>>(items: I) -> Vec<T> {
617    let set: BTreeSet<T> = items.into_iter().collect();
618    set.into_iter().collect()
619}
620
621#[cfg(test)]
622pub(crate) mod test_support {
623    use super::*;
624
625    pub fn did(label: &str) -> Did {
626        Did::new(&format!("did:exo:{label}")).expect("test DID")
627    }
628
629    pub fn ts(physical: u64) -> Timestamp {
630        Timestamp::new(physical, 0)
631    }
632
633    pub fn h256(byte: u8) -> Hash256 {
634        Hash256::from_bytes([byte; 32])
635    }
636
637    pub fn permissive_intent(purpose: &str) -> DelegatedIntent {
638        DelegatedIntent {
639            intent_id: h256(0xAA),
640            purpose: purpose.into(),
641            allowed_objectives: vec!["primary".into()],
642            prohibited_objectives: vec![],
643            autonomy_level: AutonomyLevel::Draft,
644            delegation_allowed: true,
645        }
646    }
647
648    pub fn permissive_scope() -> AuthorityScope {
649        AuthorityScope {
650            permissions: vec![Permission::Read, Permission::Write],
651            tools: vec!["alpha".into(), "beta".into()],
652            data_classes: vec![DataClass::Public, DataClass::Internal],
653            counterparties: vec![],
654            jurisdictions: vec!["US".into()],
655        }
656    }
657
658    pub fn baseline_draft() -> AvcDraft {
659        AvcDraft {
660            schema_version: AVC_SCHEMA_VERSION,
661            issuer_did: did("issuer"),
662            principal_did: did("issuer"),
663            subject_did: did("agent"),
664            holder_did: None,
665            subject_kind: AvcSubjectKind::AiAgent {
666                model_id: "alpha".into(),
667                agent_version: Some("1.0.0".into()),
668            },
669            created_at: ts(1_000_000),
670            expires_at: Some(ts(2_000_000)),
671            delegated_intent: permissive_intent("research"),
672            authority_scope: permissive_scope(),
673            constraints: AvcConstraints::permissive(),
674            authority_chain: None,
675            consent_refs: vec![],
676            policy_refs: vec![],
677            parent_avc_id: None,
678        }
679    }
680}
681
682#[cfg(test)]
683mod tests {
684    use super::{test_support::*, *};
685
686    fn fixed_signature() -> Signature {
687        Signature::from_bytes([7u8; 64])
688    }
689
690    #[test]
691    fn issue_avc_succeeds_for_valid_draft() {
692        let draft = baseline_draft();
693        let cred = issue_avc(draft, |_| fixed_signature()).unwrap();
694        assert_eq!(cred.signature, fixed_signature());
695    }
696
697    #[test]
698    fn issue_avc_normalizes_collections_and_dedupes() {
699        let mut draft = baseline_draft();
700        draft.authority_scope.tools = vec!["beta".into(), "alpha".into(), "alpha".into()];
701        draft.authority_scope.permissions =
702            vec![Permission::Write, Permission::Read, Permission::Read];
703        let cred = issue_avc(draft, |_| fixed_signature()).unwrap();
704        assert_eq!(cred.authority_scope.tools, vec!["alpha", "beta"]);
705        assert_eq!(
706            cred.authority_scope.permissions,
707            vec![Permission::Read, Permission::Write]
708        );
709    }
710
711    #[test]
712    fn issue_avc_rejects_unsupported_schema() {
713        let mut draft = baseline_draft();
714        draft.schema_version = 99;
715        let err = issue_avc(draft, |_| fixed_signature()).unwrap_err();
716        assert!(matches!(err, AvcError::UnsupportedSchema { got: 99, .. }));
717    }
718
719    #[test]
720    fn protocol_version_support_accepts_legacy_and_current_rejects_future() {
721        assert_eq!(
722            require_supported_avc_protocol_version(None).unwrap(),
723            AVC_PROTOCOL_VERSION
724        );
725        assert_eq!(
726            require_supported_avc_protocol_version(Some(AVC_PROTOCOL_VERSION)).unwrap(),
727            AVC_PROTOCOL_VERSION
728        );
729        let err = require_supported_avc_protocol_version(Some(AVC_PROTOCOL_VERSION + 1))
730            .expect_err("future AVC protocol version must fail closed");
731        assert!(matches!(
732            err,
733            AvcError::UnsupportedProtocol {
734                got,
735                min_supported: AVC_MIN_SUPPORTED_PROTOCOL_VERSION,
736                max_supported: AVC_MAX_SUPPORTED_PROTOCOL_VERSION,
737            } if got == AVC_PROTOCOL_VERSION + 1
738        ));
739    }
740
741    #[test]
742    fn issue_avc_rejects_empty_purpose() {
743        let mut draft = baseline_draft();
744        draft.delegated_intent.purpose = "   ".into();
745        let err = issue_avc(draft, |_| fixed_signature()).unwrap_err();
746        assert!(
747            matches!(err, AvcError::EmptyField { field } if field == "delegated_intent.purpose")
748        );
749    }
750
751    #[test]
752    fn issue_avc_rejects_empty_allowed_objective() {
753        let mut draft = baseline_draft();
754        draft.delegated_intent.allowed_objectives = vec!["valid".into(), "  ".into()];
755        let err = issue_avc(draft, |_| fixed_signature()).unwrap_err();
756        assert!(matches!(err, AvcError::EmptyField { .. }));
757    }
758
759    #[test]
760    fn issue_avc_rejects_empty_prohibited_objective() {
761        let mut draft = baseline_draft();
762        draft.delegated_intent.prohibited_objectives = vec!["".into()];
763        let err = issue_avc(draft, |_| fixed_signature()).unwrap_err();
764        assert!(matches!(err, AvcError::EmptyField { .. }));
765    }
766
767    #[test]
768    fn issue_avc_rejects_empty_tool_in_scope() {
769        let mut draft = baseline_draft();
770        draft.authority_scope.tools = vec!["".into()];
771        let err = issue_avc(draft, |_| fixed_signature()).unwrap_err();
772        assert!(matches!(err, AvcError::EmptyField { .. }));
773    }
774
775    #[test]
776    fn issue_avc_rejects_empty_jurisdiction() {
777        let mut draft = baseline_draft();
778        draft.authority_scope.jurisdictions = vec!["".into()];
779        let err = issue_avc(draft, |_| fixed_signature()).unwrap_err();
780        assert!(matches!(err, AvcError::EmptyField { .. }));
781    }
782
783    #[test]
784    fn issue_avc_rejects_empty_data_class_custom() {
785        let mut draft = baseline_draft();
786        draft.authority_scope.data_classes = vec![DataClass::Custom("   ".into())];
787        let err = issue_avc(draft, |_| fixed_signature()).unwrap_err();
788        assert!(matches!(err, AvcError::EmptyField { .. }));
789    }
790
791    #[test]
792    fn issue_avc_rejects_empty_currency_code() {
793        let mut draft = baseline_draft();
794        draft.constraints.currency_code = Some("   ".into());
795        let err = issue_avc(draft, |_| fixed_signature()).unwrap_err();
796        assert!(matches!(err, AvcError::EmptyField { .. }));
797    }
798
799    #[test]
800    fn issue_avc_rejects_empty_forbidden_action() {
801        let mut draft = baseline_draft();
802        draft.constraints.forbidden_actions = vec!["".into()];
803        let err = issue_avc(draft, |_| fixed_signature()).unwrap_err();
804        assert!(matches!(err, AvcError::EmptyField { .. }));
805    }
806
807    #[test]
808    fn issue_avc_rejects_empty_emergency_stop_ref() {
809        let mut draft = baseline_draft();
810        draft.constraints.emergency_stop_refs = vec!["".into()];
811        let err = issue_avc(draft, |_| fixed_signature()).unwrap_err();
812        assert!(matches!(err, AvcError::EmptyField { .. }));
813    }
814
815    #[test]
816    fn issue_avc_rejects_basis_points_out_of_range() {
817        let mut draft = baseline_draft();
818        draft.constraints.max_action_risk_bp = Some(11_000);
819        let err = issue_avc(draft, |_| fixed_signature()).unwrap_err();
820        assert!(matches!(err, AvcError::BasisPointOutOfRange { .. }));
821    }
822
823    #[test]
824    fn issue_avc_rejects_approval_threshold_out_of_range() {
825        let mut draft = baseline_draft();
826        draft.constraints.approval_threshold_bp = Some(99_999);
827        let err = issue_avc(draft, |_| fixed_signature()).unwrap_err();
828        assert!(matches!(err, AvcError::BasisPointOutOfRange { .. }));
829    }
830
831    #[test]
832    fn issue_avc_rejects_expiry_at_or_before_created_at() {
833        let mut draft = baseline_draft();
834        draft.expires_at = Some(draft.created_at);
835        let err = issue_avc(draft, |_| fixed_signature()).unwrap_err();
836        assert!(matches!(err, AvcError::InvalidTimestamp { .. }));
837    }
838
839    #[test]
840    fn issue_avc_rejects_inverted_time_window() {
841        let mut draft = baseline_draft();
842        draft.constraints.allowed_time_window = Some(TimeWindow {
843            not_before: ts(2_000),
844            not_after: ts(1_000),
845        });
846        let err = issue_avc(draft, |_| fixed_signature()).unwrap_err();
847        assert!(matches!(err, AvcError::InvalidTimestamp { .. }));
848    }
849
850    #[test]
851    fn issue_avc_rejects_empty_subject_kind_field() {
852        let mut draft = baseline_draft();
853        draft.subject_kind = AvcSubjectKind::AgentSwarm {
854            swarm_id: "".into(),
855        };
856        assert!(issue_avc(draft, |_| fixed_signature()).is_err());
857
858        let mut draft = baseline_draft();
859        draft.subject_kind = AvcSubjectKind::Workflow {
860            workflow_id: "".into(),
861        };
862        assert!(issue_avc(draft, |_| fixed_signature()).is_err());
863
864        let mut draft = baseline_draft();
865        draft.subject_kind = AvcSubjectKind::Service {
866            service_id: "".into(),
867        };
868        assert!(issue_avc(draft, |_| fixed_signature()).is_err());
869
870        let mut draft = baseline_draft();
871        draft.subject_kind = AvcSubjectKind::Holon {
872            holon_id: "".into(),
873        };
874        assert!(issue_avc(draft, |_| fixed_signature()).is_err());
875
876        let mut draft = baseline_draft();
877        draft.subject_kind = AvcSubjectKind::OrganizationUnit { unit_id: "".into() };
878        assert!(issue_avc(draft, |_| fixed_signature()).is_err());
879    }
880
881    #[test]
882    fn subject_kind_unknown_validates() {
883        let mut draft = baseline_draft();
884        draft.subject_kind = AvcSubjectKind::Unknown;
885        let cred = issue_avc(draft, |_| fixed_signature()).unwrap();
886        assert!(matches!(cred.subject_kind, AvcSubjectKind::Unknown));
887    }
888
889    #[test]
890    fn id_is_deterministic() {
891        let draft = baseline_draft();
892        let cred1 = issue_avc(draft.clone(), |_| fixed_signature()).unwrap();
893        let cred2 = issue_avc(draft, |_| fixed_signature()).unwrap();
894        assert_eq!(cred1.id().unwrap(), cred2.id().unwrap());
895    }
896
897    #[test]
898    fn id_changes_when_signed_field_changes() {
899        let draft1 = baseline_draft();
900        let mut draft2 = draft1.clone();
901        draft2.delegated_intent.purpose = "different".into();
902        let cred1 = issue_avc(draft1, |_| fixed_signature()).unwrap();
903        let cred2 = issue_avc(draft2, |_| fixed_signature()).unwrap();
904        assert_ne!(cred1.id().unwrap(), cred2.id().unwrap());
905    }
906
907    #[test]
908    fn signing_payload_contains_domain_tag() {
909        let cred = issue_avc(baseline_draft(), |_| fixed_signature()).unwrap();
910        let bytes = cred.signing_payload().unwrap();
911        let needle = AVC_CREDENTIAL_SIGNING_DOMAIN.as_bytes();
912        assert!(bytes.windows(needle.len()).any(|w| w == needle));
913    }
914
915    #[test]
916    fn signing_payload_excludes_signature_so_id_is_signature_independent() {
917        let mut cred = issue_avc(baseline_draft(), |_| fixed_signature()).unwrap();
918        let id1 = cred.id().unwrap();
919        cred.signature = Signature::from_bytes([0x42u8; 64]);
920        let id2 = cred.id().unwrap();
921        assert_eq!(id1, id2);
922    }
923
924    #[test]
925    fn id_changes_when_holder_changes() {
926        let mut draft1 = baseline_draft();
927        draft1.holder_did = Some(did("holder-a"));
928        let mut draft2 = draft1.clone();
929        draft2.holder_did = Some(did("holder-b"));
930        let id1 = issue_avc(draft1, |_| fixed_signature())
931            .unwrap()
932            .id()
933            .unwrap();
934        let id2 = issue_avc(draft2, |_| fixed_signature())
935            .unwrap()
936            .id()
937            .unwrap();
938        assert_ne!(id1, id2);
939    }
940
941    #[test]
942    fn id_changes_when_authority_chain_changes() {
943        let mut draft1 = baseline_draft();
944        draft1.authority_chain = Some(AuthorityChainRef {
945            chain_hash: h256(0x11),
946        });
947        let mut draft2 = draft1.clone();
948        draft2.authority_chain = Some(AuthorityChainRef {
949            chain_hash: h256(0x22),
950        });
951        let id1 = issue_avc(draft1, |_| fixed_signature())
952            .unwrap()
953            .id()
954            .unwrap();
955        let id2 = issue_avc(draft2, |_| fixed_signature())
956            .unwrap()
957            .id()
958            .unwrap();
959        assert_ne!(id1, id2);
960    }
961
962    #[test]
963    fn content_hash_matches_id() {
964        let cred = issue_avc(baseline_draft(), |_| fixed_signature()).unwrap();
965        assert_eq!(cred.content_hash().unwrap(), cred.id().unwrap());
966    }
967
968    #[test]
969    fn effective_holder_defaults_to_subject() {
970        let cred = issue_avc(baseline_draft(), |_| fixed_signature()).unwrap();
971        assert_eq!(cred.effective_holder(), &cred.subject_did);
972    }
973
974    #[test]
975    fn effective_holder_uses_explicit_holder_when_present() {
976        let mut draft = baseline_draft();
977        draft.holder_did = Some(did("holder-x"));
978        let cred = issue_avc(draft, |_| fixed_signature()).unwrap();
979        assert_eq!(cred.effective_holder(), &did("holder-x"));
980    }
981
982    #[test]
983    fn time_window_contains_inclusive_bounds() {
984        let window = TimeWindow {
985            not_before: ts(100),
986            not_after: ts(200),
987        };
988        assert!(window.contains(&ts(100)));
989        assert!(window.contains(&ts(150)));
990        assert!(window.contains(&ts(200)));
991        assert!(!window.contains(&ts(99)));
992        assert!(!window.contains(&ts(201)));
993    }
994
995    #[test]
996    fn autonomy_level_orderable() {
997        assert!(AutonomyLevel::ObserveOnly < AutonomyLevel::Recommend);
998        assert!(AutonomyLevel::Recommend < AutonomyLevel::Draft);
999        assert!(AutonomyLevel::Draft < AutonomyLevel::ExecuteWithHumanApproval);
1000        assert!(AutonomyLevel::ExecuteWithHumanApproval < AutonomyLevel::ExecuteWithinBounds);
1001        assert!(AutonomyLevel::ExecuteWithinBounds < AutonomyLevel::DelegateWithinBounds);
1002    }
1003
1004    #[test]
1005    fn permissions_normalize_deterministically() {
1006        let mut draft = baseline_draft();
1007        draft.authority_scope.permissions = vec![
1008            Permission::Govern,
1009            Permission::Read,
1010            Permission::Write,
1011            Permission::Read,
1012        ];
1013        let cred = issue_avc(draft, |_| fixed_signature()).unwrap();
1014        assert_eq!(
1015            cred.authority_scope.permissions,
1016            vec![Permission::Read, Permission::Write, Permission::Govern]
1017        );
1018    }
1019
1020    #[test]
1021    fn consent_and_policy_refs_normalize() {
1022        let mut draft = baseline_draft();
1023        draft.consent_refs = vec![
1024            ConsentRef {
1025                consent_id: h256(2),
1026                required: true,
1027            },
1028            ConsentRef {
1029                consent_id: h256(1),
1030                required: true,
1031            },
1032            ConsentRef {
1033                consent_id: h256(2),
1034                required: true,
1035            },
1036        ];
1037        draft.policy_refs = vec![
1038            PolicyRef {
1039                policy_id: h256(5),
1040                policy_version: 1,
1041                required: true,
1042            },
1043            PolicyRef {
1044                policy_id: h256(5),
1045                policy_version: 1,
1046                required: true,
1047            },
1048        ];
1049        let cred = issue_avc(draft, |_| fixed_signature()).unwrap();
1050        assert_eq!(cred.consent_refs.len(), 2);
1051        assert!(cred.consent_refs[0].consent_id <= cred.consent_refs[1].consent_id);
1052        assert_eq!(cred.policy_refs.len(), 1);
1053    }
1054
1055    #[test]
1056    fn permissive_constraints_validate() {
1057        let constraints = AvcConstraints::permissive();
1058        assert!(constraints.validate().is_ok());
1059    }
1060
1061    #[test]
1062    fn empty_authority_scope_validates() {
1063        let mut scope = AuthorityScope::empty();
1064        scope.normalize();
1065        assert!(scope.validate().is_ok());
1066    }
1067}