Skip to main content

a1/
chain.rs

1use blake3::Hasher;
2use ed25519_dalek::VerifyingKey;
3
4use crate::audit::{AuditEvent, AuditOutcome, AuditSink, NoopAuditSink};
5use crate::cert::DelegationCert;
6use crate::crypto::DOMAIN_CHAIN_FP;
7use crate::error::A1Error;
8use crate::intent::{IntentHash, MerkleProof};
9use crate::policy::PolicySet;
10use crate::registry::{NonceStore, RevocationStore};
11#[cfg(feature = "tracing")]
12use tracing::Instrument;
13
14// ── Clock ─────────────────────────────────────────────────────────────────────
15
16pub trait Clock {
17    fn unix_now(&self) -> u64;
18}
19
20pub struct SystemClock;
21
22impl Clock for SystemClock {
23    fn unix_now(&self) -> u64 {
24        std::time::SystemTime::now()
25            .duration_since(std::time::UNIX_EPOCH)
26            .expect("system clock is before the Unix epoch")
27            .as_secs()
28    }
29}
30
31// ── VerificationReceipt ───────────────────────────────────────────────────────
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35pub struct VerificationReceipt {
36    pub chain_depth: usize,
37    pub verified_scope_root: IntentHash,
38    pub intent: IntentHash,
39    pub verified_at_unix: u64,
40    pub chain_fingerprint: [u8; 32],
41    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
42    pub namespace: Option<String>,
43}
44
45impl VerificationReceipt {
46    pub fn fingerprint_hex(&self) -> String {
47        hex::encode(self.chain_fingerprint)
48    }
49
50    #[cfg(feature = "wire")]
51    pub(crate) fn canonical_bytes(&self) -> Vec<u8> {
52        let mut out = Vec::with_capacity(16 + 8 + 32 + 32 + 8 + 32 + 64);
53        out.extend_from_slice(b"a1_dyolo_v2.8.0:");
54        out.extend_from_slice(&(self.chain_depth as u64).to_be_bytes());
55        out.extend_from_slice(&self.verified_scope_root);
56        out.extend_from_slice(&self.intent);
57        out.extend_from_slice(&self.verified_at_unix.to_be_bytes());
58        out.extend_from_slice(&self.chain_fingerprint);
59        if let Some(ns) = &self.namespace {
60            out.extend_from_slice(&(ns.len() as u64).to_be_bytes());
61            out.extend_from_slice(ns.as_bytes());
62        } else {
63            out.extend_from_slice(&0u64.to_be_bytes());
64        }
65        out
66    }
67}
68
69// ── BatchAuthorizeResult ──────────────────────────────────────────────────────
70
71#[derive(Debug)]
72pub struct BatchAuthorizeResult {
73    pub receipts: Vec<Option<VerificationReceipt>>,
74    pub errors: Vec<Option<A1Error>>,
75    pub all_authorized: bool,
76}
77
78impl BatchAuthorizeResult {
79    pub fn authorized_count(&self) -> usize {
80        self.receipts.iter().filter(|r| r.is_some()).count()
81    }
82}
83
84// ── AuthorizedAction ──────────────────────────────────────────────────────────
85
86#[must_use]
87#[non_exhaustive]
88pub struct AuthorizedAction {
89    pub receipt: VerificationReceipt,
90}
91
92impl AuthorizedAction {
93    pub(crate) fn new(receipt: VerificationReceipt) -> Self {
94        Self { receipt }
95    }
96
97    pub fn receipt(&self) -> &VerificationReceipt {
98        &self.receipt
99    }
100}
101
102impl std::fmt::Debug for AuthorizedAction {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.debug_struct("AuthorizedAction")
105            .field("receipt", &self.receipt)
106            .finish()
107    }
108}
109
110// ── Internal validation result ────────────────────────────────────────────────
111
112struct ChainValidationResult {
113    depth: usize,
114    verified_scope_root: IntentHash,
115    verified_at_unix: u64,
116    seen_nonces: Vec<[u8; 16]>,
117    cert_fingerprints: Vec<[u8; 32]>,
118    chain_fingerprint: [u8; 32],
119}
120
121// ── Namespace scope derivation ────────────────────────────────────────────────
122
123fn namespace_scope(namespace: &str, scope: &IntentHash) -> IntentHash {
124    let mut h = blake3::Hasher::new_derive_key("a1::dyolo::namespace::scope::v2.8.0");
125    h.update(&(namespace.len() as u64).to_le_bytes());
126    h.update(namespace.as_bytes());
127    h.update(scope);
128    h.finalize().into()
129}
130
131// ── DyoloChain ────────────────────────────────────────────────────────────────
132
133#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
134pub struct DyoloChain {
135    pub principal_pk: VerifyingKey,
136    pub principal_scope: IntentHash,
137    certs: Vec<DelegationCert>,
138    pub drift_tolerance_secs: u64,
139    pub namespace: Option<String>,
140}
141
142impl DyoloChain {
143    pub fn new(principal_pk: VerifyingKey, principal_scope: IntentHash) -> Self {
144        Self {
145            principal_pk,
146            principal_scope,
147            certs: Vec::new(),
148            drift_tolerance_secs: 15,
149            namespace: None,
150        }
151    }
152
153    /// Attach a namespace to this chain.
154    ///
155    /// The effective scope root becomes a namespace-derived hash of the original
156    /// `principal_scope`, cryptographically binding the chain to one tenant namespace.
157    /// A cert issued for namespace "tenant-a" cannot authorize under namespace "tenant-b".
158    ///
159    /// # Multi-tenancy
160    ///
161    /// Use this when building a chain on behalf of a specific tenant. The human
162    /// principal must compute the namespaced scope before issuing the first cert:
163    ///
164    /// ```rust,ignore
165    /// let chain = DyoloChain::new(pk, original_scope)
166    ///     .with_namespace("acme-corp");
167    /// let namespaced_scope = chain.principal_scope;
168    /// let cert = CertBuilder::new(agent_pk, namespaced_scope, now, expiry).sign(&human);
169    /// chain.push(cert);
170    /// ```
171    pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
172        let ns = namespace.into();
173        self.principal_scope = namespace_scope(&ns, &self.principal_scope);
174        self.namespace = Some(ns);
175        self
176    }
177
178    pub fn with_drift_tolerance(mut self, secs: u64) -> Self {
179        self.drift_tolerance_secs = secs;
180        self
181    }
182
183    pub fn push(&mut self, cert: DelegationCert) -> &mut Self {
184        self.certs.push(cert);
185        self
186    }
187
188    pub fn len(&self) -> usize {
189        self.certs.len()
190    }
191    pub fn is_empty(&self) -> bool {
192        self.certs.is_empty()
193    }
194    pub fn certs(&self) -> &[DelegationCert] {
195        &self.certs
196    }
197
198    pub fn fingerprint(&self) -> [u8; 32] {
199        let mut h = Hasher::new_derive_key(DOMAIN_CHAIN_FP);
200        h.update(b"a1::dyolo::chain::v2.8.0");
201        h.update(self.principal_pk.as_bytes());
202        h.update(&self.principal_scope);
203        if let Some(ns) = &self.namespace {
204            h.update(&(ns.len() as u64).to_le_bytes());
205            h.update(ns.as_bytes());
206        } else {
207            h.update(&0u64.to_le_bytes());
208        }
209        self.certs
210            .iter()
211            .fold(h, |mut h, cert| {
212                h.update(&cert.fingerprint());
213                h
214            })
215            .finalize()
216            .into()
217    }
218
219    // ── Structural validation (CPU-only, no I/O) ──────────────────────────────
220
221    fn validate_structure(
222        &self,
223        agent_pk: &VerifyingKey,
224        intent: &IntentHash,
225        proof: &MerkleProof,
226        clock: &dyn Clock,
227        drift_tolerance: u64,
228    ) -> Result<ChainValidationResult, A1Error> {
229        if self.certs.is_empty() {
230            return Err(A1Error::EmptyChain);
231        }
232        if self.certs[0].delegator_pk != self.principal_pk {
233            return Err(A1Error::RootMismatch);
234        }
235
236        let now = clock.unix_now();
237        let tolerated_early = now.saturating_add(drift_tolerance);
238        let tolerated_late = now.saturating_sub(drift_tolerance);
239        let chain_len = self.certs.len();
240
241        if chain_len > 255 {
242            return Err(A1Error::MaxDepthExceeded(255, 255));
243        }
244
245        let mut current_scope = self.principal_scope;
246        let mut expected_delegator = self.principal_pk;
247        let mut depth: usize = 0;
248        let mut max_allowed_depth = u8::MAX;
249        let mut parent_expiry = u64::MAX;
250
251        let mut seen_nonces: Vec<[u8; 16]> = Vec::with_capacity(chain_len);
252        let mut cert_fingerprints: Vec<[u8; 32]> = Vec::with_capacity(chain_len);
253        let mut batch_sigs: Vec<ed25519_dalek::Signature> = Vec::with_capacity(chain_len);
254        let mut batch_pks: Vec<VerifyingKey> = Vec::with_capacity(chain_len);
255        let mut batch_msgs: Vec<Vec<u8>> = Vec::with_capacity(chain_len);
256
257        for (i, cert) in self.certs.iter().enumerate() {
258            if cert.delegator_pk != expected_delegator {
259                return Err(A1Error::BrokenLinkage(i));
260            }
261
262            if cert.version != crate::cert::CERT_VERSION {
263                return Err(A1Error::UnsupportedVersion {
264                    expected: crate::cert::CERT_VERSION,
265                    got: cert.version,
266                });
267            }
268
269            #[cfg(feature = "wire")]
270            let ext_commit = cert.extensions.commitment();
271            #[cfg(not(feature = "wire"))]
272            let ext_commit = cert.extensions_hash.unwrap_or_else(|| {
273                let mut h = crate::crypto::derive_key("a1::dyolo::cert::ext::v2.8.0", cert.version);
274                h.update(&0u64.to_le_bytes());
275                h.finalize().into()
276            });
277
278            batch_msgs.push(DelegationCert::signable_bytes(
279                cert.version,
280                &cert.delegator_pk,
281                &cert.delegate_pk,
282                &cert.scope_root,
283                &cert.scope_proof,
284                &cert.nonce,
285                cert.issued_at,
286                cert.expiration_unix,
287                cert.max_depth,
288                &ext_commit,
289            ));
290            batch_sigs.push(cert.signature);
291            batch_pks.push(cert.delegator_pk);
292
293            if tolerated_early < cert.issued_at {
294                return Err(A1Error::NotYetValid(i, cert.issued_at, now));
295            }
296            if cert.expiration_unix < tolerated_late {
297                return Err(A1Error::Expired(i, cert.expiration_unix, now));
298            }
299            if cert.expiration_unix > parent_expiry {
300                return Err(A1Error::TemporalViolation(
301                    i,
302                    cert.expiration_unix,
303                    parent_expiry,
304                ));
305            }
306
307            depth += 1;
308            if depth > max_allowed_depth as usize {
309                return Err(A1Error::MaxDepthExceeded(i, max_allowed_depth));
310            }
311            if cert.max_depth < max_allowed_depth {
312                max_allowed_depth = cert.max_depth;
313            }
314
315            for seen in &seen_nonces {
316                if seen == &cert.nonce {
317                    return Err(A1Error::NonceReplay);
318                }
319            }
320            seen_nonces.push(cert.nonce);
321            cert_fingerprints.push(cert.fingerprint());
322
323            let is_passthrough =
324                cert.scope_proof.subset_intents.is_empty() && cert.scope_proof.proofs.is_empty();
325            if is_passthrough {
326                use subtle::ConstantTimeEq;
327                if cert.scope_root.ct_eq(&current_scope).unwrap_u8() == 0 {
328                    return Err(A1Error::ScopeEscalation(i));
329                }
330            } else {
331                let derived = cert
332                    .scope_proof
333                    .verify_and_derive_root(&current_scope)
334                    .map_err(|_| A1Error::ScopeEscalation(i))?;
335                use subtle::ConstantTimeEq;
336                if derived.ct_eq(&cert.scope_root).unwrap_u8() == 0 {
337                    return Err(A1Error::ScopeEscalation(i));
338                }
339            }
340
341            parent_expiry = cert.expiration_unix;
342            current_scope = cert.scope_root;
343            expected_delegator = cert.delegate_pk;
344        }
345
346        if expected_delegator != *agent_pk {
347            return Err(A1Error::UnauthorizedLeaf);
348        }
349
350        {
351            let msgs_refs: Vec<&[u8]> = batch_msgs.iter().map(|m| m.as_slice()).collect();
352            if ed25519_dalek::verify_batch(&msgs_refs, &batch_sigs, &batch_pks).is_err() {
353                for (i, cert) in self.certs.iter().enumerate() {
354                    if !cert.verify_signature() {
355                        return Err(A1Error::InvalidSignature(i));
356                    }
357                }
358                return Err(A1Error::InvalidSignature(0));
359            }
360        }
361
362        let intent_authorized = if proof.siblings.is_empty() {
363            use subtle::ConstantTimeEq;
364            intent.ct_eq(&current_scope).into()
365        } else {
366            proof.verify(intent, &current_scope)
367        };
368        if !intent_authorized {
369            return Err(A1Error::ScopeViolation);
370        }
371
372        Ok(ChainValidationResult {
373            depth,
374            verified_scope_root: current_scope,
375            verified_at_unix: now,
376            seen_nonces,
377            cert_fingerprints,
378            chain_fingerprint: self.fingerprint(),
379        })
380    }
381
382    // ── authorize ─────────────────────────────────────────────────────────────
383
384    pub fn authorize(
385        &self,
386        agent_pk: &VerifyingKey,
387        intent: &IntentHash,
388        proof: &MerkleProof,
389        clock: &(dyn Clock + Send + Sync),
390        revocation: &(dyn RevocationStore + Send + Sync),
391        nonces: &(dyn NonceStore + Send + Sync),
392    ) -> Result<AuthorizedAction, A1Error> {
393        self.authorize_with_options(
394            agent_pk,
395            intent,
396            proof,
397            clock,
398            revocation,
399            nonces,
400            None,
401            &NoopAuditSink,
402        )
403    }
404
405    #[allow(clippy::too_many_arguments)]
406    pub fn authorize_with_options(
407        &self,
408        agent_pk: &VerifyingKey,
409        intent_h: &IntentHash,
410        proof: &MerkleProof,
411        clock: &(dyn Clock + Send + Sync),
412        revocation: &(dyn RevocationStore + Send + Sync),
413        nonces: &(dyn NonceStore + Send + Sync),
414        policy: Option<&PolicySet>,
415        sink: &dyn AuditSink,
416    ) -> Result<AuthorizedAction, A1Error> {
417        #[cfg(feature = "tracing")]
418        let _span = tracing::info_span!("a1::authorize", chain_len = self.certs.len()).entered();
419
420        let principal_hex = hex::encode(self.principal_pk.as_bytes());
421        let executor_hex = hex::encode(agent_pk.as_bytes());
422
423        let result =
424            self.authorize_inner(agent_pk, intent_h, proof, clock, revocation, nonces, policy);
425
426        let outcome = match &result {
427            Ok(_) => AuditOutcome::Authorized,
428            Err(A1Error::PolicyViolation(_)) => AuditOutcome::PolicyViolation,
429            Err(e) if e.is_transient_storage_failure() => AuditOutcome::StorageError,
430            Err(_) => AuditOutcome::Denied,
431        };
432
433        let mut event = AuditEvent::new(
434            outcome,
435            principal_hex,
436            executor_hex,
437            self.certs.len(),
438            intent_h,
439            clock.unix_now(),
440        );
441
442        if let Ok(action) = &result {
443            event = event.with_fingerprint(action.receipt.chain_fingerprint);
444            #[cfg(feature = "tracing")]
445            tracing::info!(
446                chain_depth = action.receipt.chain_depth,
447                chain_fingerprint = %action.receipt.fingerprint_hex(),
448                "a1: authorization succeeded"
449            );
450        } else if let Err(e) = &result {
451            event = event.with_error(e.to_string());
452            #[cfg(feature = "tracing")]
453            tracing::warn!(error = %e, "a1: authorization failed");
454        }
455
456        sink.emit(event);
457        result
458    }
459
460    #[allow(clippy::too_many_arguments)]
461    fn authorize_inner(
462        &self,
463        agent_pk: &VerifyingKey,
464        intent_h: &IntentHash,
465        proof: &MerkleProof,
466        clock: &(dyn Clock + Send + Sync),
467        revocation: &(dyn RevocationStore + Send + Sync),
468        nonces: &(dyn NonceStore + Send + Sync),
469        policy: Option<&PolicySet>,
470    ) -> Result<AuthorizedAction, A1Error> {
471        if let Some(p) = policy {
472            p.check_chain(self)?;
473        }
474
475        let v =
476            self.validate_structure(agent_pk, intent_h, proof, clock, self.drift_tolerance_secs)?;
477
478        for fp in &v.cert_fingerprints {
479            if revocation.is_revoked(fp).map_err(A1Error::StorageFailure)? {
480                return Err(A1Error::Revoked);
481            }
482        }
483
484        if !nonces
485            .try_consume_batch(&v.seen_nonces)
486            .map_err(A1Error::StorageFailure)?
487        {
488            return Err(A1Error::NonceReplay);
489        }
490
491        Ok(AuthorizedAction::new(VerificationReceipt {
492            chain_depth: v.depth,
493            verified_scope_root: v.verified_scope_root,
494            intent: *intent_h,
495            verified_at_unix: v.verified_at_unix,
496            chain_fingerprint: v.chain_fingerprint,
497            namespace: self.namespace.clone(),
498        }))
499    }
500
501    // ── authorize_batch ───────────────────────────────────────────────────────
502
503    pub fn authorize_batch(
504        &self,
505        agent_pk: &VerifyingKey,
506        intents: &[(IntentHash, MerkleProof)],
507        clock: &(dyn Clock + Send + Sync),
508        revocation: &(dyn RevocationStore + Send + Sync),
509        nonces: &(dyn NonceStore + Send + Sync),
510    ) -> BatchAuthorizeResult {
511        if intents.is_empty() {
512            return BatchAuthorizeResult {
513                receipts: Vec::new(),
514                errors: Vec::new(),
515                all_authorized: true,
516            };
517        }
518
519        let now = clock.unix_now();
520        let first_intent = &intents[0].0;
521        let first_proof = &intents[0].1;
522
523        let v = match self.validate_structure(
524            agent_pk,
525            first_intent,
526            first_proof,
527            clock,
528            self.drift_tolerance_secs,
529        ) {
530            Ok(v) => v,
531            Err(e) => {
532                let n = intents.len();
533                let msg = e.to_string();
534                return BatchAuthorizeResult {
535                    receipts: vec![None; n],
536                    errors: (0..n)
537                        .map(|i| {
538                            Some(A1Error::BatchItemFailed {
539                                index: i,
540                                reason: msg.clone(),
541                            })
542                        })
543                        .collect(),
544                    all_authorized: false,
545                };
546            }
547        };
548
549        for fp in &v.cert_fingerprints {
550            match revocation.is_revoked(fp) {
551                Ok(true) => {
552                    let n = intents.len();
553                    return BatchAuthorizeResult {
554                        receipts: vec![None; n],
555                        errors: (0..n).map(|_| Some(A1Error::Revoked)).collect(),
556                        all_authorized: false,
557                    };
558                }
559                Err(e) => {
560                    let n = intents.len();
561                    let msg = A1Error::StorageFailure(e).to_string();
562                    return BatchAuthorizeResult {
563                        receipts: vec![None; n],
564                        errors: (0..n)
565                            .map(|_| {
566                                Some(A1Error::BatchItemFailed {
567                                    index: 0,
568                                    reason: msg.clone(),
569                                })
570                            })
571                            .collect(),
572                        all_authorized: false,
573                    };
574                }
575                Ok(false) => {}
576            }
577        }
578
579        let mut receipts: Vec<Option<VerificationReceipt>> = Vec::with_capacity(intents.len());
580        let mut errors: Vec<Option<A1Error>> = Vec::with_capacity(intents.len());
581        let mut all_ok = true;
582
583        for (i, (intent_h, proof)) in intents.iter().enumerate() {
584            let intent_authorized = if proof.siblings.is_empty() {
585                use subtle::ConstantTimeEq;
586                intent_h.ct_eq(&v.verified_scope_root).into()
587            } else {
588                proof.verify(intent_h, &v.verified_scope_root)
589            };
590
591            if intent_authorized {
592                receipts.push(Some(VerificationReceipt {
593                    chain_depth: v.depth,
594                    verified_scope_root: v.verified_scope_root,
595                    intent: *intent_h,
596                    verified_at_unix: now,
597                    chain_fingerprint: v.chain_fingerprint,
598                    namespace: self.namespace.clone(),
599                }));
600                errors.push(None);
601            } else {
602                receipts.push(None);
603                errors.push(Some(A1Error::BatchItemFailed {
604                    index: i,
605                    reason: A1Error::ScopeViolation.to_string(),
606                }));
607                all_ok = false;
608            }
609        }
610
611        if !all_ok {
612            return BatchAuthorizeResult {
613                receipts,
614                errors,
615                all_authorized: false,
616            };
617        }
618
619        match nonces.try_consume_batch(&v.seen_nonces) {
620            Ok(true) => {}
621            Ok(false) => {
622                let n = intents.len();
623                return BatchAuthorizeResult {
624                    receipts: vec![None; n],
625                    errors: (0..n).map(|_| Some(A1Error::NonceReplay)).collect(),
626                    all_authorized: false,
627                };
628            }
629            Err(e) => {
630                let n = intents.len();
631                let msg = A1Error::StorageFailure(e).to_string();
632                return BatchAuthorizeResult {
633                    receipts: vec![None; n],
634                    errors: (0..n)
635                        .map(|_| {
636                            Some(A1Error::BatchItemFailed {
637                                index: 0,
638                                reason: msg.clone(),
639                            })
640                        })
641                        .collect(),
642                    all_authorized: false,
643                };
644            }
645        }
646
647        BatchAuthorizeResult {
648            receipts,
649            errors,
650            all_authorized: true,
651        }
652    }
653
654    // ── authorize_async ───────────────────────────────────────────────────────
655
656    #[cfg(feature = "async")]
657    pub async fn authorize_async(
658        &self,
659        agent_pk: &VerifyingKey,
660        intent: &IntentHash,
661        proof: &MerkleProof,
662        clock: &(dyn Clock + Send + Sync),
663        revocation: &(dyn crate::registry::r#async::AsyncRevocationStore + Send + Sync),
664        nonces: &(dyn crate::registry::r#async::AsyncNonceStore + Send + Sync),
665    ) -> Result<AuthorizedAction, A1Error> {
666        self.authorize_async_with_options(
667            agent_pk,
668            intent,
669            proof,
670            clock,
671            revocation,
672            nonces,
673            None,
674            &NoopAuditSink,
675        )
676        .await
677    }
678
679    #[cfg(feature = "async")]
680    #[allow(clippy::too_many_arguments)]
681    pub async fn authorize_async_with_options(
682        &self,
683        agent_pk: &VerifyingKey,
684        intent_h: &IntentHash,
685        proof: &MerkleProof,
686        clock: &(dyn Clock + Send + Sync),
687        revocation: &(dyn crate::registry::r#async::AsyncRevocationStore + Send + Sync),
688        nonces: &(dyn crate::registry::r#async::AsyncNonceStore + Send + Sync),
689        policy: Option<&PolicySet>,
690        sink: &dyn AuditSink,
691    ) -> Result<AuthorizedAction, A1Error> {
692        let principal_hex = hex::encode(self.principal_pk.as_bytes());
693        let executor_hex = hex::encode(agent_pk.as_bytes());
694
695        #[cfg(feature = "tracing")]
696        let span = tracing::info_span!("a1::authorize_async", chain_len = self.certs.len());
697
698        let result = async {
699            if let Some(p) = policy {
700                p.check_chain(self)?;
701            }
702
703            let v = self.validate_structure(
704                agent_pk,
705                intent_h,
706                proof,
707                clock,
708                self.drift_tolerance_secs,
709            )?;
710
711            for fp in &v.cert_fingerprints {
712                if revocation
713                    .is_revoked(fp)
714                    .await
715                    .map_err(A1Error::StorageFailure)?
716                {
717                    return Err(A1Error::Revoked);
718                }
719            }
720
721            if !nonces
722                .try_consume_batch(&v.seen_nonces)
723                .await
724                .map_err(A1Error::StorageFailure)?
725            {
726                return Err(A1Error::NonceReplay);
727            }
728
729            Ok(AuthorizedAction::new(VerificationReceipt {
730                chain_depth: v.depth,
731                verified_scope_root: v.verified_scope_root,
732                intent: *intent_h,
733                verified_at_unix: v.verified_at_unix,
734                chain_fingerprint: v.chain_fingerprint,
735                namespace: self.namespace.clone(),
736            }))
737        };
738
739        #[cfg(feature = "tracing")]
740        let result = result.instrument(span).await;
741        #[cfg(not(feature = "tracing"))]
742        let result = result.await;
743
744        let outcome = match &result {
745            Ok(_) => AuditOutcome::Authorized,
746            Err(A1Error::PolicyViolation(_)) => AuditOutcome::PolicyViolation,
747            Err(e) if e.is_transient_storage_failure() => AuditOutcome::StorageError,
748            Err(_) => AuditOutcome::Denied,
749        };
750
751        let mut event = AuditEvent::new(
752            outcome,
753            principal_hex,
754            executor_hex,
755            self.certs.len(),
756            intent_h,
757            clock.unix_now(),
758        );
759
760        if let Ok(action) = &result {
761            event = event.with_fingerprint(action.receipt.chain_fingerprint);
762        } else if let Err(e) = &result {
763            event = event.with_error(e.to_string());
764        }
765
766        sink.emit(event);
767        result
768    }
769
770    #[cfg(feature = "async")]
771    pub async fn authorize_batch_async(
772        &self,
773        agent_pk: &VerifyingKey,
774        intents: &[(IntentHash, MerkleProof)],
775        clock: &(dyn Clock + Send + Sync),
776        revocation: &(dyn crate::registry::r#async::AsyncRevocationStore + Send + Sync),
777        nonces: &(dyn crate::registry::r#async::AsyncNonceStore + Send + Sync),
778    ) -> BatchAuthorizeResult {
779        if intents.is_empty() {
780            return BatchAuthorizeResult {
781                receipts: Vec::new(),
782                errors: Vec::new(),
783                all_authorized: true,
784            };
785        }
786
787        let now = clock.unix_now();
788        let first_intent = &intents[0].0;
789        let first_proof = &intents[0].1;
790
791        let v = match self.validate_structure(
792            agent_pk,
793            first_intent,
794            first_proof,
795            clock,
796            self.drift_tolerance_secs,
797        ) {
798            Ok(v) => v,
799            Err(e) => {
800                let n = intents.len();
801                let msg = e.to_string();
802                return BatchAuthorizeResult {
803                    receipts: vec![None; n],
804                    errors: (0..n)
805                        .map(|i| {
806                            Some(A1Error::BatchItemFailed {
807                                index: i,
808                                reason: msg.clone(),
809                            })
810                        })
811                        .collect(),
812                    all_authorized: false,
813                };
814            }
815        };
816
817        for fp in &v.cert_fingerprints {
818            match revocation.is_revoked(fp).await {
819                Ok(true) => {
820                    let n = intents.len();
821                    return BatchAuthorizeResult {
822                        receipts: vec![None; n],
823                        errors: (0..n).map(|_| Some(A1Error::Revoked)).collect(),
824                        all_authorized: false,
825                    };
826                }
827                Err(e) => {
828                    let n = intents.len();
829                    let msg = A1Error::StorageFailure(e).to_string();
830                    return BatchAuthorizeResult {
831                        receipts: vec![None; n],
832                        errors: (0..n)
833                            .map(|_| {
834                                Some(A1Error::BatchItemFailed {
835                                    index: 0,
836                                    reason: msg.clone(),
837                                })
838                            })
839                            .collect(),
840                        all_authorized: false,
841                    };
842                }
843                Ok(false) => {}
844            }
845        }
846
847        let mut receipts: Vec<Option<VerificationReceipt>> = Vec::with_capacity(intents.len());
848        let mut errors: Vec<Option<A1Error>> = Vec::with_capacity(intents.len());
849        let mut all_ok = true;
850
851        for (i, (intent_h, proof)) in intents.iter().enumerate() {
852            let intent_authorized = if proof.siblings.is_empty() {
853                use subtle::ConstantTimeEq;
854                intent_h.ct_eq(&v.verified_scope_root).into()
855            } else {
856                proof.verify(intent_h, &v.verified_scope_root)
857            };
858
859            if intent_authorized {
860                receipts.push(Some(VerificationReceipt {
861                    chain_depth: v.depth,
862                    verified_scope_root: v.verified_scope_root,
863                    intent: *intent_h,
864                    verified_at_unix: now,
865                    chain_fingerprint: v.chain_fingerprint,
866                    namespace: self.namespace.clone(),
867                }));
868                errors.push(None);
869            } else {
870                receipts.push(None);
871                errors.push(Some(A1Error::BatchItemFailed {
872                    index: i,
873                    reason: A1Error::ScopeViolation.to_string(),
874                }));
875                all_ok = false;
876            }
877        }
878
879        if !all_ok {
880            return BatchAuthorizeResult {
881                receipts,
882                errors,
883                all_authorized: false,
884            };
885        }
886
887        match nonces.try_consume_batch(&v.seen_nonces).await {
888            Ok(true) => {}
889            Ok(false) => {
890                let n = intents.len();
891                return BatchAuthorizeResult {
892                    receipts: vec![None; n],
893                    errors: (0..n).map(|_| Some(A1Error::NonceReplay)).collect(),
894                    all_authorized: false,
895                };
896            }
897            Err(e) => {
898                let n = intents.len();
899                let msg = A1Error::StorageFailure(e).to_string();
900                return BatchAuthorizeResult {
901                    receipts: vec![None; n],
902                    errors: (0..n)
903                        .map(|_| {
904                            Some(A1Error::BatchItemFailed {
905                                index: 0,
906                                reason: msg.clone(),
907                            })
908                        })
909                        .collect(),
910                    all_authorized: false,
911                };
912            }
913        }
914
915        BatchAuthorizeResult {
916            receipts,
917            errors,
918            all_authorized: true,
919        }
920    }
921}
922
923impl Clone for DyoloChain {
924    fn clone(&self) -> Self {
925        Self {
926            principal_pk: self.principal_pk,
927            principal_scope: self.principal_scope,
928            certs: self.certs.clone(),
929            drift_tolerance_secs: self.drift_tolerance_secs,
930            namespace: self.namespace.clone(),
931        }
932    }
933}
934
935// ── Tests ─────────────────────────────────────────────────────────────────────
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940    #[allow(deprecated)]
941    use crate::{
942        cert::CertBuilder,
943        identity::DyoloIdentity,
944        intent::{intent_hash, IntentTree},
945        registry::{MemoryNonceStore, MemoryRevocationStore},
946    };
947
948    struct FixedClock(u64);
949    impl Clock for FixedClock {
950        fn unix_now(&self) -> u64 {
951            self.0
952        }
953    }
954
955    #[allow(deprecated)]
956    fn setup() -> (DyoloIdentity, DyoloIdentity, DyoloIdentity, IntentTree, u64) {
957        let human = DyoloIdentity::generate();
958        let agent_a = DyoloIdentity::generate();
959        let agent_b = DyoloIdentity::generate();
960        let trade = intent_hash("TRADE_AAPL_100", b"limit=182.50");
961        let query = intent_hash("QUERY_PORTFOLIO", b"");
962        let tree = IntentTree::build(vec![trade, query]).unwrap();
963        let now = 1_700_000_000u64;
964        (human, agent_a, agent_b, tree, now)
965    }
966
967    #[allow(deprecated)]
968    fn two_hop_chain(
969        human: &DyoloIdentity,
970        a: &DyoloIdentity,
971        b: &DyoloIdentity,
972        scope: IntentHash,
973        now: u64,
974    ) -> DyoloChain {
975        let expiry = now + 3600;
976        let ca = CertBuilder::new(a.verifying_key(), scope, now, expiry).sign(human);
977        let cb = CertBuilder::new(b.verifying_key(), scope, now, expiry).sign(a);
978        let mut chain = DyoloChain::new(human.verifying_key(), scope);
979        chain.push(ca).push(cb);
980        chain
981    }
982
983    #[test]
984    #[allow(deprecated)]
985    fn full_delegation_chain_succeeds() {
986        let (human, a, b, tree, now) = setup();
987        let root = tree.root();
988        let trade = intent_hash("TRADE_AAPL_100", b"limit=182.50");
989        let proof = tree.prove(&trade).unwrap();
990        let chain = two_hop_chain(&human, &a, &b, root, now);
991        let action = chain
992            .authorize(
993                &b.verifying_key(),
994                &trade,
995                &proof,
996                &FixedClock(now),
997                &MemoryRevocationStore::new(),
998                &MemoryNonceStore::new(),
999            )
1000            .unwrap();
1001        assert_eq!(action.receipt.chain_depth, 2);
1002        assert_eq!(action.receipt.intent, trade);
1003        assert!(action.receipt.namespace.is_none());
1004    }
1005
1006    #[test]
1007    #[allow(deprecated)]
1008    fn namespace_isolation_different_scopes() {
1009        let human = DyoloIdentity::generate();
1010        let agent = DyoloIdentity::generate();
1011        let now = SystemClock.unix_now();
1012
1013        // Two namespaced chains from the same base scope must produce different principal_scopes
1014        let base_scope: IntentHash = intent_hash("trade", b"");
1015        let chain_a = DyoloChain::new(human.verifying_key(), base_scope).with_namespace("tenant-a");
1016        let chain_b = DyoloChain::new(human.verifying_key(), base_scope).with_namespace("tenant-b");
1017
1018        assert_ne!(
1019            chain_a.principal_scope, chain_b.principal_scope,
1020            "namespaced chains must have different effective scopes"
1021        );
1022        assert_ne!(chain_a.fingerprint(), chain_b.fingerprint());
1023
1024        // The namespaced principal_scope IS the intent for a single-scope chain.
1025        // A cert whose scope_root = scope_a can authorize scope_a as the intent
1026        // (empty proof: intent must equal current_scope after traversal).
1027        let scope_a = chain_a.principal_scope;
1028        let scope_b = chain_b.principal_scope;
1029
1030        let cert_a =
1031            CertBuilder::new(agent.verifying_key(), scope_a, now, now + 86400).sign(&human);
1032        let cert_b =
1033            CertBuilder::new(agent.verifying_key(), scope_b, now, now + 86400).sign(&human);
1034
1035        // Tenant-a chain authorizes scope_a as the intent
1036        let mut ca = DyoloChain::new(human.verifying_key(), scope_a);
1037        ca.push(cert_a);
1038        let ok_a = ca.authorize(
1039            &agent.verifying_key(),
1040            &scope_a,
1041            &MerkleProof::default(),
1042            &FixedClock(now),
1043            &MemoryRevocationStore::new(),
1044            &MemoryNonceStore::new(),
1045        );
1046        assert!(
1047            ok_a.is_ok(),
1048            "tenant-a cert should authorize under tenant-a scope: {:?}",
1049            ok_a.err()
1050        );
1051
1052        // Tenant-b chain authorizes scope_b as the intent
1053        let mut cb = DyoloChain::new(human.verifying_key(), scope_b);
1054        cb.push(cert_b);
1055        let ok_b = cb.authorize(
1056            &agent.verifying_key(),
1057            &scope_b,
1058            &MerkleProof::default(),
1059            &FixedClock(now),
1060            &MemoryRevocationStore::new(),
1061            &MemoryNonceStore::new(),
1062        );
1063        assert!(
1064            ok_b.is_ok(),
1065            "tenant-b cert should authorize under tenant-b scope: {:?}",
1066            ok_b.err()
1067        );
1068
1069        // Cross-namespace: cert issued for scope_b must fail under a chain expecting scope_a
1070        // because cert.scope_root (scope_b) != ca.principal_scope (scope_a) => BrokenLinkage or ScopeEscalation
1071        let cert_b_wrong =
1072            CertBuilder::new(agent.verifying_key(), scope_b, now, now + 86400).sign(&human);
1073        let mut c_wrong = DyoloChain::new(human.verifying_key(), scope_a);
1074        c_wrong.push(cert_b_wrong);
1075        let result = c_wrong.authorize(
1076            &agent.verifying_key(),
1077            &scope_a,
1078            &MerkleProof::default(),
1079            &FixedClock(now),
1080            &MemoryRevocationStore::new(),
1081            &MemoryNonceStore::new(),
1082        );
1083        assert!(
1084            result.is_err(),
1085            "cert scoped to tenant-b must not work under tenant-a chain"
1086        );
1087    }
1088
1089    #[test]
1090    #[allow(deprecated)]
1091    fn batch_authorize_all_or_nothing() {
1092        let (human, a, b, tree, now) = setup();
1093        let root = tree.root();
1094        let trade = intent_hash("TRADE_AAPL_100", b"limit=182.50");
1095        let query = intent_hash("QUERY_PORTFOLIO", b"");
1096        let t_proof = tree.prove(&trade).unwrap();
1097        let q_proof = tree.prove(&query).unwrap();
1098        let chain = two_hop_chain(&human, &a, &b, root, now);
1099        let result = chain.authorize_batch(
1100            &b.verifying_key(),
1101            &[(trade, t_proof), (query, q_proof)],
1102            &FixedClock(now),
1103            &MemoryRevocationStore::new(),
1104            &MemoryNonceStore::new(),
1105        );
1106        assert!(result.all_authorized);
1107        assert_eq!(result.authorized_count(), 2);
1108    }
1109
1110    #[test]
1111    #[allow(deprecated)]
1112    fn chain_fingerprint_stable() {
1113        let (human, a, b, tree, now) = setup();
1114        let chain = two_hop_chain(&human, &a, &b, tree.root(), now);
1115        assert_eq!(chain.fingerprint(), chain.clone().fingerprint());
1116    }
1117
1118    #[cfg(feature = "async")]
1119    #[tokio::test]
1120    #[allow(deprecated)]
1121    async fn authorize_async_succeeds() {
1122        use crate::registry::r#async::{SyncNonceAdapter, SyncRevocationAdapter};
1123        use std::sync::Arc;
1124        let (human, a, b, tree, now) = setup();
1125        let root = tree.root();
1126        let trade = intent_hash("TRADE_AAPL_100", b"limit=182.50");
1127        let proof = tree.prove(&trade).unwrap();
1128        let chain = two_hop_chain(&human, &a, &b, root, now);
1129        let rev = SyncRevocationAdapter(Arc::new(MemoryRevocationStore::new()));
1130        let nonces = SyncNonceAdapter(Arc::new(MemoryNonceStore::new()));
1131        let action = chain
1132            .authorize_async(
1133                &b.verifying_key(),
1134                &trade,
1135                &proof,
1136                &FixedClock(now),
1137                &rev,
1138                &nonces,
1139            )
1140            .await
1141            .unwrap();
1142        assert_eq!(action.receipt.chain_depth, 2);
1143    }
1144}