Skip to main content

a3s_code_core/capability/
projection.rs

1use std::collections::{BTreeMap, VecDeque};
2use std::fmt;
3use std::sync::{Arc, Mutex};
4
5use tokio::time::Instant;
6
7use crate::cognitive_context::CognitiveContextSession;
8use crate::commands::SlashCommand;
9use crate::context::ContextProvider;
10use crate::hooks::HookBinding;
11use crate::skills::Skill;
12use crate::subagent::AgentDefinition;
13use crate::tools::Tool;
14
15use super::{
16    CapabilityEffect, CapabilityId, CapabilityKind, CapabilityProjectionError,
17    CapabilityReadinessPlan, CapabilitySet, CapabilityValue, CodeCatalogGeneration,
18    KnowledgeSurfaceBinding, McpBinding, ScopeClosePolicy, Sha256Digest, UiBinding,
19    UseGenerationLeaseProvider,
20};
21
22/// Immutable pairing of one identity set with exactly one typed runtime value
23/// for every descriptor.
24#[derive(Debug)]
25pub struct CapabilityProjection {
26    set: Arc<CapabilitySet>,
27    readiness: Arc<CapabilityReadinessPlan>,
28    values: BTreeMap<CapabilityId, CapabilityValue>,
29}
30
31impl CapabilityProjection {
32    pub fn new(
33        set: Arc<CapabilitySet>,
34        values: impl IntoIterator<Item = (CapabilityId, CapabilityValue)>,
35    ) -> Result<Arc<Self>, CapabilityProjectionError> {
36        let readiness = Arc::new(CapabilityReadinessPlan::from_set(&set)?);
37        Self::with_readiness(set, readiness, values)
38    }
39
40    pub(super) fn with_readiness(
41        set: Arc<CapabilitySet>,
42        readiness: Arc<CapabilityReadinessPlan>,
43        values: impl IntoIterator<Item = (CapabilityId, CapabilityValue)>,
44    ) -> Result<Arc<Self>, CapabilityProjectionError> {
45        if !readiness.matches(&set) {
46            return Err(CapabilityProjectionError::ReadinessPlanMismatch {
47                expected_generation: set.generation().get(),
48                actual_generation: readiness.generation().get(),
49                digest_mismatch: readiness.digest() != set.digest(),
50            });
51        }
52        let mut canonical = BTreeMap::new();
53        for (id, value) in values {
54            if canonical.insert(id.clone(), value).is_some() {
55                return Err(CapabilityProjectionError::DuplicateValue {
56                    capability: id.to_string(),
57                });
58            }
59        }
60
61        for (id, descriptor) in set.iter() {
62            let Some(value) = canonical.get(id) else {
63                return Err(CapabilityProjectionError::MissingValue {
64                    capability: id.to_string(),
65                });
66            };
67            if value.kind() != descriptor.id().kind() {
68                return Err(CapabilityProjectionError::KindMismatch {
69                    capability: id.to_string(),
70                    descriptor_kind: descriptor.id().kind(),
71                    value_kind: value.kind(),
72                });
73            }
74            if value
75                .public_name()
76                .is_some_and(|actual| actual != descriptor.public_name())
77            {
78                return Err(CapabilityProjectionError::PublicNameMismatch {
79                    capability: id.to_string(),
80                    expected: descriptor.public_name().to_owned(),
81                    actual: value
82                        .public_name()
83                        .expect("is_some_and already observed a public name")
84                        .to_owned(),
85                });
86            }
87            if let CapabilityValue::Ui(binding) = value {
88                if descriptor.surface_digest() != binding.surface_digest() {
89                    return Err(CapabilityProjectionError::SurfaceDigestMismatch {
90                        capability: id.to_string(),
91                        expected: descriptor.surface_digest().to_string(),
92                        actual: binding.surface_digest().to_string(),
93                    });
94                }
95                for dependency in descriptor.dependencies() {
96                    if !matches!(
97                        dependency.kind(),
98                        CapabilityKind::Tool
99                            | CapabilityKind::Skill
100                            | CapabilityKind::Mcp
101                            | CapabilityKind::Flow
102                    ) {
103                        return Err(CapabilityProjectionError::UnsupportedUiDependencyKind {
104                            capability: id.to_string(),
105                            dependency: dependency.to_string(),
106                            dependency_kind: dependency.kind(),
107                        });
108                    }
109                }
110            }
111            if let CapabilityValue::KnowledgeSurface(binding) = value {
112                if descriptor.surface_digest() != binding.surface_digest() {
113                    return Err(CapabilityProjectionError::SurfaceDigestMismatch {
114                        capability: id.to_string(),
115                        expected: descriptor.surface_digest().to_string(),
116                        actual: binding.surface_digest().to_string(),
117                    });
118                }
119            }
120        }
121        for id in canonical.keys() {
122            if !set.contains(id) {
123                return Err(CapabilityProjectionError::UnexpectedValue {
124                    capability: id.to_string(),
125                });
126            }
127        }
128
129        Ok(Arc::new(Self {
130            set,
131            readiness,
132            values: canonical,
133        }))
134    }
135
136    pub fn set(&self) -> &CapabilitySet {
137        &self.set
138    }
139
140    pub(crate) fn set_arc(&self) -> &Arc<CapabilitySet> {
141        &self.set
142    }
143
144    pub fn readiness_plan(&self) -> &CapabilityReadinessPlan {
145        &self.readiness
146    }
147
148    pub fn len(&self) -> usize {
149        self.values.len()
150    }
151
152    pub fn is_empty(&self) -> bool {
153        self.values.is_empty()
154    }
155
156    pub fn contains(&self, id: &CapabilityId) -> bool {
157        self.values.contains_key(id)
158    }
159
160    pub fn iter(&self) -> impl ExactSizeIterator<Item = (&CapabilityId, &CapabilityValue)> {
161        self.values.iter()
162    }
163
164    pub fn tool(&self, id: &CapabilityId) -> Option<&dyn Tool> {
165        match self.values.get(id) {
166            Some(CapabilityValue::Tool(value)) => Some(value.as_ref()),
167            _ => None,
168        }
169    }
170
171    pub fn skill(&self, id: &CapabilityId) -> Option<&Skill> {
172        match self.values.get(id) {
173            Some(CapabilityValue::Skill(value)) => Some(value.as_ref()),
174            _ => None,
175        }
176    }
177
178    pub fn agent(&self, id: &CapabilityId) -> Option<&AgentDefinition> {
179        match self.values.get(id) {
180            Some(CapabilityValue::Agent(value)) => Some(value.as_ref()),
181            _ => None,
182        }
183    }
184
185    pub fn command(&self, id: &CapabilityId) -> Option<&dyn SlashCommand> {
186        match self.values.get(id) {
187            Some(CapabilityValue::Command(value)) => Some(value.as_ref()),
188            _ => None,
189        }
190    }
191
192    pub fn hook(&self, id: &CapabilityId) -> Option<&HookBinding> {
193        match self.values.get(id) {
194            Some(CapabilityValue::Hook(value)) => Some(value.as_ref()),
195            _ => None,
196        }
197    }
198
199    pub fn mcp(&self, id: &CapabilityId) -> Option<&McpBinding> {
200        match self.values.get(id) {
201            Some(CapabilityValue::Mcp(value)) => Some(value.as_ref()),
202            _ => None,
203        }
204    }
205
206    #[cfg(feature = "dynamic-workflow")]
207    pub fn flow(&self, id: &CapabilityId) -> Option<&crate::capability::FlowBinding> {
208        match self.values.get(id) {
209            Some(CapabilityValue::Flow(value)) => Some(value.as_ref()),
210            _ => None,
211        }
212    }
213
214    pub fn knowledge(&self, id: &CapabilityId) -> Option<&CognitiveContextSession> {
215        match self.values.get(id) {
216            Some(CapabilityValue::Knowledge(value)) => Some(value.as_ref()),
217            _ => None,
218        }
219    }
220
221    pub fn knowledge_surface(&self, id: &CapabilityId) -> Option<&KnowledgeSurfaceBinding> {
222        match self.values.get(id) {
223            Some(CapabilityValue::KnowledgeSurface(value)) => Some(value.as_ref()),
224            _ => None,
225        }
226    }
227
228    pub fn ui(&self, id: &CapabilityId) -> Option<&UiBinding> {
229        match self.values.get(id) {
230            Some(CapabilityValue::Ui(value)) => Some(value.as_ref()),
231            _ => None,
232        }
233    }
234
235    pub fn context(&self, id: &CapabilityId) -> Option<&dyn ContextProvider> {
236        match self.values.get(id) {
237            Some(CapabilityValue::Context(value)) => Some(value.as_ref()),
238            _ => None,
239        }
240    }
241}
242
243/// Exact local generation and identity digest used by catalog CAS publication.
244#[derive(Clone, Debug, Eq, PartialEq)]
245pub struct CapabilityCatalogStamp {
246    generation: CodeCatalogGeneration,
247    digest: Sha256Digest,
248}
249
250impl CapabilityCatalogStamp {
251    fn from_projection(projection: &CapabilityProjection) -> Self {
252        Self {
253            generation: projection.set().generation(),
254            digest: projection.set().digest().clone(),
255        }
256    }
257
258    pub const fn generation(&self) -> CodeCatalogGeneration {
259        self.generation
260    }
261
262    pub fn digest(&self) -> &Sha256Digest {
263        &self.digest
264    }
265}
266
267/// Successful all-or-nothing projection publication evidence.
268#[derive(Clone, Debug, Eq, PartialEq)]
269pub struct CapabilityCommitReceipt {
270    previous: CapabilityCatalogStamp,
271    committed: CapabilityCatalogStamp,
272}
273
274impl CapabilityCommitReceipt {
275    pub fn previous(&self) -> &CapabilityCatalogStamp {
276        &self.previous
277    }
278
279    pub fn committed(&self) -> &CapabilityCatalogStamp {
280        &self.committed
281    }
282}
283
284#[derive(Clone, Copy)]
285enum CleanupReason {
286    Rollback,
287    Retired,
288}
289
290struct CleanupBatch {
291    reason: CleanupReason,
292    effects: Vec<Box<dyn CapabilityEffect>>,
293}
294
295#[derive(Default)]
296struct CleanupQueue {
297    batches: Mutex<VecDeque<CleanupBatch>>,
298}
299
300impl CleanupQueue {
301    fn enqueue(&self, reason: CleanupReason, effects: Vec<Box<dyn CapabilityEffect>>) {
302        if effects.is_empty() {
303            return;
304        }
305        self.batches
306            .lock()
307            .unwrap_or_else(std::sync::PoisonError::into_inner)
308            .push_back(CleanupBatch { reason, effects });
309    }
310
311    fn take_all(&self) -> VecDeque<CleanupBatch> {
312        std::mem::take(
313            &mut *self
314                .batches
315                .lock()
316                .unwrap_or_else(std::sync::PoisonError::into_inner),
317        )
318    }
319
320    fn len(&self) -> usize {
321        self.batches
322            .lock()
323            .unwrap_or_else(std::sync::PoisonError::into_inner)
324            .len()
325    }
326}
327
328struct PublishedGeneration {
329    projection: Arc<CapabilityProjection>,
330    stamp: CapabilityCatalogStamp,
331    use_lease_provider: Option<Arc<dyn UseGenerationLeaseProvider>>,
332    effects: Mutex<Vec<Box<dyn CapabilityEffect>>>,
333    cleanup: Arc<CleanupQueue>,
334}
335
336impl PublishedGeneration {
337    fn new(
338        projection: Arc<CapabilityProjection>,
339        use_lease_provider: Option<Arc<dyn UseGenerationLeaseProvider>>,
340        effects: Vec<Box<dyn CapabilityEffect>>,
341        cleanup: Arc<CleanupQueue>,
342    ) -> Self {
343        let stamp = CapabilityCatalogStamp::from_projection(&projection);
344        Self {
345            projection,
346            stamp,
347            use_lease_provider,
348            effects: Mutex::new(effects),
349            cleanup,
350        }
351    }
352}
353
354impl Drop for PublishedGeneration {
355    fn drop(&mut self) {
356        let effects = std::mem::take(
357            self.effects
358                .get_mut()
359                .unwrap_or_else(std::sync::PoisonError::into_inner),
360        );
361        self.cleanup.enqueue(CleanupReason::Retired, effects);
362    }
363}
364
365struct CatalogState {
366    current: Arc<PublishedGeneration>,
367}
368
369pub(super) struct CatalogInner {
370    state: Mutex<CatalogState>,
371    cleanup: Arc<CleanupQueue>,
372}
373
374impl CatalogInner {
375    pub(super) fn current_stamp(&self) -> CapabilityCatalogStamp {
376        self.state
377            .lock()
378            .unwrap_or_else(std::sync::PoisonError::into_inner)
379            .current
380            .stamp
381            .clone()
382    }
383
384    pub(super) fn enqueue_rollback(&self, effects: Vec<Box<dyn CapabilityEffect>>) {
385        self.cleanup.enqueue(CleanupReason::Rollback, effects);
386    }
387
388    pub(super) fn publish(
389        &self,
390        base: &CapabilityCatalogStamp,
391        projection: Arc<CapabilityProjection>,
392        use_lease_provider: Option<Arc<dyn UseGenerationLeaseProvider>>,
393        effects: Vec<Box<dyn CapabilityEffect>>,
394    ) -> Result<CapabilityCommitReceipt, CapabilityProjectionError> {
395        let committed = CapabilityCatalogStamp::from_projection(&projection);
396        let old = {
397            let mut state = self
398                .state
399                .lock()
400                .unwrap_or_else(std::sync::PoisonError::into_inner);
401            let actual = &state.current.stamp;
402            if actual != base {
403                let error = CapabilityProjectionError::CommitConflict {
404                    expected_generation: base.generation().get(),
405                    expected_digest: base.digest().to_string(),
406                    actual_generation: actual.generation().get(),
407                    actual_digest: actual.digest().to_string(),
408                };
409                drop(state);
410                self.enqueue_rollback(effects);
411                return Err(error);
412            }
413            let published = Arc::new(PublishedGeneration::new(
414                projection,
415                use_lease_provider,
416                effects,
417                Arc::clone(&self.cleanup),
418            ));
419            std::mem::replace(&mut state.current, published)
420        };
421        let previous = old.stamp.clone();
422        drop(old);
423        Ok(CapabilityCommitReceipt {
424            previous,
425            committed,
426        })
427    }
428}
429
430/// Session-local immutable capability publication catalog.
431///
432/// Readers only clone one `Arc` under a short mutex and then resolve through a
433/// pinned [`CapabilityProjectionLease`]. Writers use an exact generation and
434/// digest compare-and-swap; a losing writer cannot mutate the current value.
435pub struct CapabilityCatalog {
436    pub(super) inner: Arc<CatalogInner>,
437}
438
439impl CapabilityCatalog {
440    pub fn new(initial: Arc<CapabilityProjection>) -> Self {
441        let cleanup = Arc::new(CleanupQueue::default());
442        let current = Arc::new(PublishedGeneration::new(
443            initial,
444            None,
445            Vec::new(),
446            Arc::clone(&cleanup),
447        ));
448        Self {
449            inner: Arc::new(CatalogInner {
450                state: Mutex::new(CatalogState { current }),
451                cleanup,
452            }),
453        }
454    }
455
456    pub fn current_stamp(&self) -> CapabilityCatalogStamp {
457        self.inner.current_stamp()
458    }
459
460    pub fn pin(&self) -> CapabilityProjectionLease {
461        let generation = Arc::clone(
462            &self
463                .inner
464                .state
465                .lock()
466                .unwrap_or_else(std::sync::PoisonError::into_inner)
467                .current,
468        );
469        CapabilityProjectionLease { generation }
470    }
471
472    pub fn pending_cleanup_batches(&self) -> usize {
473        self.inner.cleanup.len()
474    }
475
476    pub(crate) fn retire_current_effects(&self) {
477        let generation = Arc::clone(
478            &self
479                .inner
480                .state
481                .lock()
482                .unwrap_or_else(std::sync::PoisonError::into_inner)
483                .current,
484        );
485        let effects = std::mem::take(
486            &mut *generation
487                .effects
488                .lock()
489                .unwrap_or_else(std::sync::PoisonError::into_inner),
490        );
491        self.inner.cleanup.enqueue(CleanupReason::Retired, effects);
492    }
493
494    pub async fn drain_cleanup(&self) -> CapabilityCleanupReport {
495        self.drain_cleanup_with_policy(ScopeClosePolicy::default())
496            .await
497    }
498
499    pub async fn drain_cleanup_with_policy(
500        &self,
501        policy: ScopeClosePolicy,
502    ) -> CapabilityCleanupReport {
503        let mut batches = self.inner.cleanup.take_all();
504        let deadline = Instant::now() + policy.timeout();
505        let mut report = CapabilityCleanupReport::default();
506
507        while let Some(mut batch) = batches.pop_front() {
508            match batch.reason {
509                CleanupReason::Rollback => report.rollback_batches += 1,
510                CleanupReason::Retired => report.retired_batches += 1,
511            }
512            while let Some(effect) = batch.effects.pop() {
513                if Instant::now() >= deadline {
514                    report.effects_timed_out += 1 + batch.effects.len();
515                    break;
516                }
517                match tokio::time::timeout_at(deadline, effect.close()).await {
518                    Ok(Ok(())) => report.effects_closed += 1,
519                    Ok(Err(_)) => report.effects_failed += 1,
520                    Err(_) => report.effects_timed_out += 1,
521                }
522            }
523        }
524        report
525    }
526}
527
528impl fmt::Debug for CapabilityCatalog {
529    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
530        formatter
531            .debug_struct("CapabilityCatalog")
532            .field("current", &self.current_stamp())
533            .field("pending_cleanup_batches", &self.pending_cleanup_batches())
534            .finish()
535    }
536}
537
538/// Non-clone reader lease retaining one exact projected generation.
539///
540/// The contained values are exposed by borrow, so ordinary execution cannot
541/// accidentally switch to the catalog's latest generation. When the last
542/// lease and catalog pointer to a retired generation disappear, Rust `Arc`
543/// ownership moves its effects to the asynchronous cleanup queue.
544#[must_use = "a projection lease pins one exact catalog generation"]
545pub struct CapabilityProjectionLease {
546    generation: Arc<PublishedGeneration>,
547}
548
549impl CapabilityProjectionLease {
550    pub fn stamp(&self) -> &CapabilityCatalogStamp {
551        &self.generation.stamp
552    }
553
554    pub fn projection(&self) -> &CapabilityProjection {
555        &self.generation.projection
556    }
557
558    pub(super) fn use_lease_provider(&self) -> Option<&Arc<dyn UseGenerationLeaseProvider>> {
559        self.generation.use_lease_provider.as_ref()
560    }
561}
562
563impl fmt::Debug for CapabilityProjectionLease {
564    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
565        formatter
566            .debug_struct("CapabilityProjectionLease")
567            .field("stamp", self.stamp())
568            .finish_non_exhaustive()
569    }
570}
571
572/// Bounded reverse-teardown result for retired or rolled-back effects.
573#[derive(Clone, Debug, Default, Eq, PartialEq)]
574pub struct CapabilityCleanupReport {
575    pub rollback_batches: usize,
576    pub retired_batches: usize,
577    pub effects_closed: usize,
578    pub effects_failed: usize,
579    pub effects_timed_out: usize,
580}
581
582impl CapabilityCleanupReport {
583    pub const fn is_clean(&self) -> bool {
584        self.effects_failed == 0 && self.effects_timed_out == 0
585    }
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use crate::capability::{CapabilitySource, Sha256Digest};
592    use crate::cognitive_context::CognitiveContextProvider;
593    use crate::hooks::HookHandler;
594    use crate::skills::{Skill, SkillKind};
595
596    fn digest(byte: char) -> Sha256Digest {
597        assert!(
598            byte.is_ascii_hexdigit(),
599            "test digests must use hex characters"
600        );
601        Sha256Digest::new(format!("sha256:{}", byte.to_string().repeat(64))).unwrap()
602    }
603
604    fn sample_skill_value(name: &str) -> CapabilityValue {
605        CapabilityValue::Skill(Arc::new(Skill {
606            name: name.to_owned(),
607            description: "coverage".to_owned(),
608            allowed_tools: None,
609            disable_model_invocation: false,
610            kind: SkillKind::Instruction,
611            content: "body".to_owned(),
612            tags: vec![],
613            version: None,
614        }))
615    }
616
617    #[test]
618    fn empty_projection_exposes_accessors_and_none_lookups() {
619        let set = CapabilitySet::empty().unwrap();
620        let projection = CapabilityProjection::new(Arc::clone(&set), []).unwrap();
621        assert!(projection.is_empty());
622        assert_eq!(projection.len(), 0);
623        assert_eq!(projection.iter().len(), 0);
624        assert!(Arc::ptr_eq(projection.set_arc(), &set));
625        assert_eq!(projection.set().generation().get(), set.generation().get());
626        assert_eq!(
627            projection.readiness_plan().generation().get(),
628            set.generation().get()
629        );
630
631        let source = CapabilitySource::builtin("a3s-code", digest('a')).unwrap();
632        let missing = CapabilityId::new(&source, CapabilityKind::Skill, "missing").unwrap();
633        assert!(!projection.contains(&missing));
634        assert!(projection.tool(&missing).is_none());
635        assert!(projection.skill(&missing).is_none());
636        assert!(projection.agent(&missing).is_none());
637        assert!(projection.command(&missing).is_none());
638        assert!(projection.hook(&missing).is_none());
639        assert!(projection.mcp(&missing).is_none());
640        assert!(projection.knowledge(&missing).is_none());
641        assert!(projection.knowledge_surface(&missing).is_none());
642        assert!(projection.ui(&missing).is_none());
643        assert!(projection.context(&missing).is_none());
644    }
645
646    #[test]
647    fn projection_rejects_duplicate_and_unexpected_values() {
648        let set = CapabilitySet::empty().unwrap();
649        let source = CapabilitySource::builtin("a3s-code", digest('b')).unwrap();
650        let id = CapabilityId::new(&source, CapabilityKind::Skill, "orphan").unwrap();
651        let value = sample_skill_value("orphan");
652
653        let duplicate = CapabilityProjection::new(
654            Arc::clone(&set),
655            [(id.clone(), value.clone()), (id.clone(), value.clone())],
656        );
657        assert!(matches!(
658            duplicate,
659            Err(CapabilityProjectionError::DuplicateValue { .. })
660        ));
661
662        let unexpected = CapabilityProjection::new(Arc::clone(&set), [(id, value)]);
663        assert!(matches!(
664            unexpected,
665            Err(CapabilityProjectionError::UnexpectedValue { .. })
666        ));
667    }
668
669    #[test]
670    fn readiness_plan_mismatch_fails_closed() {
671        let set = CapabilitySet::empty().unwrap();
672        let other = CapabilitySet::from_contributions(
673            CodeCatalogGeneration::new(2),
674            Vec::<crate::capability::CapabilityContribution>::new(),
675        )
676        .unwrap();
677        let readiness = Arc::new(CapabilityReadinessPlan::from_set(&other).unwrap());
678        let err = CapabilityProjection::with_readiness(set, readiness, []);
679        assert!(matches!(
680            err,
681            Err(CapabilityProjectionError::ReadinessPlanMismatch { .. })
682        ));
683    }
684
685    #[test]
686    fn projection_with_skill_covers_typed_lookups() {
687        let source = CapabilitySource::builtin("a3s-code", digest('c')).unwrap();
688        let descriptor = crate::capability::CapabilityDescriptor::new(
689            &source,
690            CapabilityKind::Skill,
691            "coverage-skill",
692            "coverage-skill",
693            digest('d'),
694            [],
695        )
696        .unwrap();
697        let id = descriptor.id().clone();
698        let set = CapabilitySet::from_contributions(
699            CodeCatalogGeneration::new(1),
700            [crate::capability::CapabilityContribution::new(source, [descriptor]).unwrap()],
701        )
702        .unwrap();
703        let value = sample_skill_value("coverage-skill");
704        let projection =
705            CapabilityProjection::new(Arc::clone(&set), [(id.clone(), value)]).unwrap();
706        assert_eq!(projection.len(), 1);
707        assert!(!projection.is_empty());
708        assert!(projection.contains(&id));
709        assert!(projection.skill(&id).is_some());
710        assert!(projection.tool(&id).is_none());
711        assert!(projection.agent(&id).is_none());
712        assert!(projection.command(&id).is_none());
713        assert!(projection.hook(&id).is_none());
714        assert!(projection.mcp(&id).is_none());
715        assert!(projection.knowledge(&id).is_none());
716        assert!(projection.knowledge_surface(&id).is_none());
717        assert!(projection.ui(&id).is_none());
718        assert!(projection.context(&id).is_none());
719
720        let catalog = CapabilityCatalog::new(Arc::clone(&projection));
721        let rendered = format!("{catalog:?}");
722        assert!(rendered.contains("CapabilityCatalog"));
723        assert!(rendered.contains("pending_cleanup_batches"));
724        let lease = catalog.pin();
725        let lease_dbg = format!("{lease:?}");
726        assert!(lease_dbg.contains("CapabilityProjectionLease"));
727        assert_eq!(lease.projection().len(), 1);
728        assert_eq!(lease.stamp().generation().get(), set.generation().get());
729    }
730
731    struct SlowEffect;
732    struct FailingEffect;
733
734    #[async_trait::async_trait]
735    impl CapabilityEffect for SlowEffect {
736        fn name(&self) -> &str {
737            "slow-coverage"
738        }
739
740        async fn close(self: Box<Self>) -> Result<(), crate::capability::CapabilityEffectError> {
741            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
742            Ok(())
743        }
744    }
745
746    #[async_trait::async_trait]
747    impl CapabilityEffect for FailingEffect {
748        fn name(&self) -> &str {
749            "failing-coverage"
750        }
751
752        async fn close(self: Box<Self>) -> Result<(), crate::capability::CapabilityEffectError> {
753            Err(crate::capability::CapabilityEffectError::new("boom"))
754        }
755    }
756
757    #[tokio::test]
758    async fn drain_cleanup_reports_failed_and_timed_out_effects() {
759        let set = CapabilitySet::empty().unwrap();
760        let projection = CapabilityProjection::new(Arc::clone(&set), []).unwrap();
761        let catalog = CapabilityCatalog::new(projection);
762
763        let failing: Box<dyn crate::capability::CapabilityEffect> = Box::new(FailingEffect);
764        assert_eq!(failing.name(), "failing-coverage");
765        catalog.inner.enqueue_rollback(vec![failing]);
766        let failed = catalog.drain_cleanup().await;
767        assert_eq!(failed.effects_failed, 1);
768        assert!(!failed.is_clean());
769
770        let slow: Box<dyn crate::capability::CapabilityEffect> = Box::new(SlowEffect);
771        assert_eq!(slow.name(), "slow-coverage");
772        catalog
773            .inner
774            .enqueue_rollback(vec![slow, Box::new(SlowEffect), Box::new(SlowEffect)]);
775        let policy = ScopeClosePolicy::new(std::time::Duration::from_millis(5)).expect("policy");
776        let timed_out = catalog.drain_cleanup_with_policy(policy).await;
777        assert!(
778            timed_out.effects_timed_out > 0,
779            "expected timeout accounting, got {timed_out:?}"
780        );
781    }
782
783    struct CoverageCommand;
784
785    impl crate::commands::SlashCommand for CoverageCommand {
786        fn name(&self) -> &str {
787            "coverage-cmd"
788        }
789
790        fn description(&self) -> &str {
791            "projection accessor coverage"
792        }
793
794        fn execute(
795            &self,
796            _args: &str,
797            _ctx: &crate::commands::CommandContext,
798        ) -> crate::commands::CommandOutput {
799            crate::commands::CommandOutput::text("ok")
800        }
801    }
802
803    struct CoverageHookHandler;
804
805    impl crate::hooks::HookHandler for CoverageHookHandler {
806        fn handle(&self, _event: &crate::hooks::HookEvent) -> crate::hooks::HookResponse {
807            crate::hooks::HookResponse::continue_()
808        }
809    }
810
811    struct CoverageKnowledgeProvider;
812
813    #[async_trait::async_trait]
814    impl crate::cognitive_context::CognitiveContextProvider for CoverageKnowledgeProvider {
815        fn name(&self) -> &str {
816            "coverage-knowledge"
817        }
818
819        async fn query(
820            &self,
821            _request: &crate::cognitive_context::CognitiveContextRequestV1,
822        ) -> crate::cognitive_context::CognitiveContextResult<
823            crate::cognitive_context::CognitiveContextResponseV1,
824        > {
825            Err(crate::cognitive_context::CognitiveContextError::Provider(
826                "coverage provider is query-less".into(),
827            ))
828        }
829    }
830
831    fn sample_knowledge_value() -> CapabilityValue {
832        let generation_digest =
833            "sha256:aa0beeb62f1b7b21bf70f21e6f0e858a1e4b720d313f0907209b5b9dad2eeb20";
834        let knowledge = crate::cognitive_context::CognitiveKnowledgeBindingV1::new(
835            "domain-knowledge",
836            "0.2",
837            "sha256:1def786da6d190b7b3ce0176e71d99ff1cac3f8c8cc7c0f8b76a893c544e7a90",
838            7,
839            generation_digest,
840        )
841        .unwrap();
842        let binding = crate::cognitive_context::CognitivePackageBindingV1::new(
843            "contra-sense/handbook",
844            "0.1.0",
845            7,
846            generation_digest,
847            "sha256:1e0f0a0162f5b290887ade8886af69fbba4548c863df026178e3550c77813455",
848            knowledge,
849            crate::cognitive_context::CognitiveContextLimits::default(),
850        )
851        .unwrap();
852        CapabilityValue::Knowledge(Arc::new(
853            crate::cognitive_context::CognitiveContextSession::new(
854                binding,
855                Arc::new(CoverageKnowledgeProvider),
856            )
857            .unwrap(),
858        ))
859    }
860
861    #[tokio::test]
862    async fn projection_typed_some_accessors_hit_each_capability_arm() {
863        let source = CapabilitySource::builtin("a3s-code", digest('e')).unwrap();
864        let kinds = [
865            (CapabilityKind::Agent, "coverage-agent", "coverage-agent"),
866            (CapabilityKind::Command, "coverage-cmd", "coverage-cmd"),
867            (CapabilityKind::Hook, "coverage-hook", "coverage-hook"),
868            (CapabilityKind::Mcp, "coverage-mcp", "coverage-mcp"),
869            (
870                CapabilityKind::Knowledge,
871                "coverage-knowledge",
872                "coverage-knowledge",
873            ),
874            (
875                CapabilityKind::Context,
876                "coverage-context",
877                "coverage-context",
878            ),
879        ];
880        let descriptors = kinds
881            .iter()
882            .enumerate()
883            .map(|(index, (kind, name, public_name))| {
884                crate::capability::CapabilityDescriptor::new(
885                    &source,
886                    *kind,
887                    *name,
888                    *public_name,
889                    digest(char::from_digit((index + 1) as u32, 16).unwrap_or('f')),
890                    [],
891                )
892                .unwrap()
893            })
894            .collect::<Vec<_>>();
895        let ids = descriptors
896            .iter()
897            .map(|descriptor| descriptor.id().clone())
898            .collect::<Vec<_>>();
899        let set = CapabilitySet::from_contributions(
900            CodeCatalogGeneration::new(3),
901            [crate::capability::CapabilityContribution::new(source, descriptors).unwrap()],
902        )
903        .unwrap();
904
905        let (mcp_binding, _transport, _client) = crate::mcp::test_support::ready_binding(
906            "coverage-mcp",
907            "v1",
908            vec![crate::mcp::test_support::mcp_tool("ping", "ping")],
909        )
910        .await;
911        let values = [
912            (
913                ids[0].clone(),
914                CapabilityValue::Agent(Arc::new(crate::subagent::AgentDefinition::new(
915                    "coverage-agent",
916                    "projection coverage",
917                ))),
918            ),
919            (
920                ids[1].clone(),
921                CapabilityValue::Command(Arc::new(CoverageCommand)),
922            ),
923            (
924                ids[2].clone(),
925                CapabilityValue::Hook(Arc::new(crate::hooks::HookBinding::new(
926                    crate::hooks::Hook::new(
927                        "coverage-hook",
928                        crate::hooks::HookEventType::PreToolUse,
929                    ),
930                    Arc::new(CoverageHookHandler),
931                ))),
932            ),
933            (ids[3].clone(), CapabilityValue::Mcp(mcp_binding)),
934            (ids[4].clone(), sample_knowledge_value()),
935            (
936                ids[5].clone(),
937                CapabilityValue::Context(Arc::new(crate::context::StaticContextProvider::new(
938                    "coverage-context",
939                ))),
940            ),
941        ];
942        let projection = CapabilityProjection::new(Arc::clone(&set), values).unwrap();
943        assert!(projection.agent(&ids[0]).is_some());
944        assert_eq!(projection.agent(&ids[0]).unwrap().name, "coverage-agent");
945        assert!(projection.command(&ids[1]).is_some());
946        let command = projection.command(&ids[1]).unwrap();
947        assert_eq!(command.name(), "coverage-cmd");
948        assert_eq!(command.description(), "projection accessor coverage");
949        let command_ctx = crate::commands::CommandContext {
950            session_id: "coverage".into(),
951            workspace: "/tmp".into(),
952            model: "test".into(),
953            history_len: 0,
954            total_tokens: 0,
955            total_cost: 0.0,
956            tool_names: vec![],
957            mcp_servers: vec![],
958        };
959        assert_eq!(command.execute("", &command_ctx).text, "ok");
960        assert!(projection.hook(&ids[2]).is_some());
961        assert_eq!(projection.hook(&ids[2]).unwrap().hook().id, "coverage-hook");
962        let hook_response = CoverageHookHandler.handle(&crate::hooks::HookEvent::SessionStart(
963            crate::hooks::SessionStartEvent {
964                session_id: "coverage".into(),
965                system_prompt: None,
966                model_provider: "test".into(),
967                model_name: "coverage".into(),
968            },
969        ));
970        assert_eq!(hook_response.action, crate::hooks::HookAction::Continue);
971        assert!(projection.mcp(&ids[3]).is_some());
972        assert_eq!(
973            projection.mcp(&ids[3]).unwrap().server_name(),
974            "coverage-mcp"
975        );
976        assert!(projection.knowledge(&ids[4]).is_some());
977        assert_eq!(
978            projection.knowledge(&ids[4]).unwrap().provider_name(),
979            "coverage-knowledge"
980        );
981        let knowledge_provider = CoverageKnowledgeProvider;
982        assert_eq!(
983            crate::cognitive_context::CognitiveContextProvider::name(&knowledge_provider),
984            "coverage-knowledge"
985        );
986        let knowledge_binding = projection.knowledge(&ids[4]).unwrap().binding().clone();
987        let knowledge_request = crate::cognitive_context::CognitiveContextRequestV1::new(
988            "coverage-session",
989            "coverage query",
990            knowledge_binding,
991        )
992        .expect("knowledge request");
993        let knowledge_err = knowledge_provider
994            .query(&knowledge_request)
995            .await
996            .expect_err("coverage provider stays query-less");
997        assert!(matches!(
998            knowledge_err,
999            crate::cognitive_context::CognitiveContextError::Provider(_)
1000        ));
1001        assert!(projection.context(&ids[5]).is_some());
1002        assert_eq!(
1003            projection.context(&ids[5]).unwrap().name(),
1004            "coverage-context"
1005        );
1006        // Wrong-kind lookups stay None.
1007        assert!(projection.skill(&ids[0]).is_none());
1008        assert!(projection.ui(&ids[1]).is_none());
1009        assert!(projection.tool(&ids[5]).is_none());
1010        assert!(projection.knowledge_surface(&ids[4]).is_none());
1011    }
1012
1013    #[test]
1014    fn projection_rejects_ui_surface_digest_and_dependency_mismatches() {
1015        let source = CapabilitySource::builtin("a3s-code", digest('9')).unwrap();
1016        let document = crate::capability::UiDocument::new(
1017            crate::capability::UiAsset::new(
1018                crate::capability::UiAssetKind::Html,
1019                "<!doctype html><main>ui</main>",
1020            )
1021            .unwrap(),
1022            [],
1023            [],
1024        )
1025        .unwrap();
1026        let ui = crate::capability::UiBinding::new(crate::capability::UiBindingSpec {
1027            public_name: "ui-surface".to_owned(),
1028            title: "UI".to_owned(),
1029            description: "surface coverage".to_owned(),
1030            icon: "panel-top".to_owned(),
1031            order: 1,
1032            document,
1033        })
1034        .unwrap();
1035        let bad_digest = digest('a');
1036        let descriptor = crate::capability::CapabilityDescriptor::new(
1037            &source,
1038            CapabilityKind::Ui,
1039            "ui-surface",
1040            "ui-surface",
1041            bad_digest,
1042            [],
1043        )
1044        .unwrap();
1045        let id = descriptor.id().clone();
1046        let set = CapabilitySet::from_contributions(
1047            CodeCatalogGeneration::new(5),
1048            [
1049                crate::capability::CapabilityContribution::new(source.clone(), [descriptor])
1050                    .unwrap(),
1051            ],
1052        )
1053        .unwrap();
1054        let err = CapabilityProjection::new(
1055            Arc::clone(&set),
1056            [(id, CapabilityValue::Ui(Arc::new(ui.clone())))],
1057        );
1058        assert!(matches!(
1059            err,
1060            Err(CapabilityProjectionError::SurfaceDigestMismatch { .. })
1061        ));
1062
1063        let agent_dep = CapabilityId::new(&source, CapabilityKind::Agent, "blocked-dep").unwrap();
1064        let agent_descriptor = crate::capability::CapabilityDescriptor::new(
1065            &source,
1066            CapabilityKind::Agent,
1067            "blocked-dep",
1068            "blocked-dep",
1069            digest('b'),
1070            [],
1071        )
1072        .unwrap();
1073        let agent_id = agent_descriptor.id().clone();
1074        let descriptor = crate::capability::CapabilityDescriptor::new(
1075            &source,
1076            CapabilityKind::Ui,
1077            "ui-surface",
1078            "ui-surface",
1079            ui.surface_digest().clone(),
1080            [agent_dep],
1081        )
1082        .unwrap();
1083        let id = descriptor.id().clone();
1084        let set = CapabilitySet::from_contributions(
1085            CodeCatalogGeneration::new(6),
1086            [crate::capability::CapabilityContribution::new(
1087                source,
1088                [agent_descriptor, descriptor],
1089            )
1090            .unwrap()],
1091        )
1092        .unwrap();
1093        let err = CapabilityProjection::new(
1094            Arc::clone(&set),
1095            [
1096                (
1097                    agent_id,
1098                    CapabilityValue::Agent(Arc::new(crate::subagent::AgentDefinition::new(
1099                        "blocked-dep",
1100                        "dependency present only to admit the ui descriptor",
1101                    ))),
1102                ),
1103                (id, CapabilityValue::Ui(Arc::new(ui))),
1104            ],
1105        );
1106        assert!(matches!(
1107            err,
1108            Err(CapabilityProjectionError::UnsupportedUiDependencyKind { .. })
1109        ));
1110    }
1111
1112    #[test]
1113    fn projection_rejects_public_name_mismatch() {
1114        let source = CapabilitySource::builtin("a3s-code", digest('7')).unwrap();
1115        let descriptor = crate::capability::CapabilityDescriptor::new(
1116            &source,
1117            CapabilityKind::Skill,
1118            "expected-name",
1119            "expected-name",
1120            digest('8'),
1121            [],
1122        )
1123        .unwrap();
1124        let id = descriptor.id().clone();
1125        let set = CapabilitySet::from_contributions(
1126            CodeCatalogGeneration::new(4),
1127            [crate::capability::CapabilityContribution::new(source, [descriptor]).unwrap()],
1128        )
1129        .unwrap();
1130        let err =
1131            CapabilityProjection::new(Arc::clone(&set), [(id, sample_skill_value("actual-name"))]);
1132        assert!(matches!(
1133            err,
1134            Err(CapabilityProjectionError::PublicNameMismatch { .. })
1135        ));
1136    }
1137}