Skip to main content

af_workflow/
registry.rs

1//! Node-type registry. Port of `platform/registry.py`.
2//!
3//! Maps a node type string (`transform.state_append`) to a factory that builds
4//! a [`StepNode`] from its config. Products extend the registry with their own
5//! business node types without touching this crate.
6
7use std::collections::{BTreeMap, HashMap};
8
9use serde_json::Value;
10
11use crate::node::StepNode;
12use crate::{CapabilityManifest, CapabilityPin, GuardKind};
13
14/// Node construction or registry failure.
15#[derive(Debug, thiserror::Error)]
16pub enum NodeError {
17    /// Unknown node type '' (not registered).
18    #[error("unknown node type '{0}' (not registered)")]
19    UnknownType(String),
20    /// Node '`node_type`' has invalid config: `reason`.
21    #[error("node '{node_type}' has invalid config: {reason}")]
22    InvalidConfig {
23        /// Node type being built.
24        node_type: String,
25        /// What is wrong with the config.
26        reason: String,
27    },
28    /// Invalid capability manifest.
29    #[error("invalid capability manifest: {0}")]
30    InvalidCapability(String),
31    /// Capability '' is already registered.
32    #[error("capability '{0}' is already registered")]
33    DuplicateCapability(String),
34}
35
36/// Builds a step node from its (raw, unresolved) config value.
37pub type StepFactory = fn(config: &Value) -> Result<Box<dyn StepNode>, NodeError>;
38
39/// Config field type in a node schema.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum FieldType {
43    /// String.
44    String,
45    /// Number.
46    Number,
47    /// Boolean.
48    Bool,
49    /// Array.
50    Array,
51    /// Object.
52    Object,
53    /// Any JSON value.
54    Any,
55}
56
57/// One config field.
58#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
59pub struct FieldSpec {
60    /// Field name.
61    pub key: String,
62    /// Expected type.
63    pub ty: FieldType,
64    /// Whether it must be present.
65    pub required: bool,
66}
67
68impl NodeSchema {
69    /// JSON Schema projection used by forms and the same validator rule.
70    pub fn to_json_schema(&self) -> Value {
71        let mut properties = serde_json::Map::new();
72        let mut required = Vec::new();
73        for field in &self.fields {
74            let schema = match field.ty {
75                FieldType::String => serde_json::json!({"type":"string"}),
76                FieldType::Number => serde_json::json!({"type":"number"}),
77                FieldType::Bool => serde_json::json!({"type":"boolean"}),
78                FieldType::Array => serde_json::json!({"type":"array"}),
79                FieldType::Object => serde_json::json!({"type":"object"}),
80                FieldType::Any => serde_json::json!({}),
81            };
82            properties.insert(field.key.clone(), schema);
83            if field.required {
84                required.push(field.key.clone());
85            }
86        }
87        serde_json::json!({"type":"object","properties":properties,"required":required,"additionalProperties":false})
88    }
89}
90
91impl FieldSpec {
92    /// A required field.
93    pub fn required(key: impl Into<String>, ty: FieldType) -> Self {
94        Self {
95            key: key.into(),
96            ty,
97            required: true,
98        }
99    }
100
101    /// An optional field.
102    pub fn optional(key: impl Into<String>, ty: FieldType) -> Self {
103        Self {
104            key: key.into(),
105            ty,
106            required: false,
107        }
108    }
109}
110
111/// Config schema of a node type.
112#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
113pub struct NodeSchema {
114    /// Fields in declaration order.
115    pub fields: Vec<FieldSpec>,
116}
117
118/// Registry of step-node factories. Ingress types are tracked by name only —
119/// the runner owns their execution.
120#[derive(Debug, Clone)]
121pub struct NodeRegistry {
122    steps: HashMap<String, StepFactory>,
123    ingress: std::collections::HashSet<String>,
124    fan_out: std::collections::HashSet<String>,
125    guard_kinds: HashMap<String, GuardKind>,
126    capabilities: BTreeMap<CapabilityPin, CapabilityManifest>,
127    capability_steps: BTreeMap<CapabilityPin, StepFactory>,
128    capability_schemas: BTreeMap<CapabilityPin, NodeSchema>,
129    capability_guards: BTreeMap<CapabilityPin, GuardKind>,
130    capability_fan_out: std::collections::BTreeSet<CapabilityPin>,
131    schemas: HashMap<String, NodeSchema>,
132}
133
134impl NodeRegistry {
135    /// Empty registry — no node types known.
136    pub fn empty() -> Self {
137        Self {
138            steps: HashMap::new(),
139            ingress: Default::default(),
140            fan_out: Default::default(),
141            guard_kinds: Default::default(),
142            capabilities: Default::default(),
143            capability_steps: Default::default(),
144            capability_schemas: Default::default(),
145            capability_guards: Default::default(),
146            capability_fan_out: Default::default(),
147            schemas: HashMap::new(),
148        }
149    }
150
151    /// Registry pre-loaded with the generic (non-business) node types.
152    pub fn with_builtins() -> Self {
153        let mut r = Self::empty();
154        crate::builtins::register_builtins(&mut r);
155        r
156    }
157
158    /// Register a step-node factory under `node_type`.
159    pub fn register_step(&mut self, node_type: impl Into<String>, factory: StepFactory) {
160        self.steps.insert(node_type.into(), factory);
161    }
162
163    /// Mark a node type as an ingress source.
164    pub fn register_ingress(&mut self, node_type: impl Into<String>) {
165        self.ingress.insert(node_type.into());
166    }
167
168    /// Mark a node type as fan-out-capable (its `process` may return `FanOut`).
169    /// The validator (R4') bans these upstream of `execute.*`.
170    pub fn register_fan_out(&mut self, node_type: impl Into<String>) {
171        self.fan_out.insert(node_type.into());
172    }
173
174    /// Whether the node type may return `FanOut`.
175    pub fn is_fan_out_capable(&self, node_type: &str) -> bool {
176        self.fan_out.contains(node_type)
177    }
178
179    /// Declare a product node as an authorization guard for `execute.*` side effects.
180    pub fn register_side_effect_guard(
181        &mut self,
182        node_type: impl Into<String>,
183    ) -> Result<(), NodeError> {
184        self.register_guard(node_type, GuardKind::Authorization)
185    }
186
187    /// Declare the role a guard plays on an action's dominating path.
188    pub fn register_guard(
189        &mut self,
190        node_type: impl Into<String>,
191        kind: GuardKind,
192    ) -> Result<(), NodeError> {
193        let node_type = node_type.into();
194        let mut manifests = self
195            .capabilities
196            .values()
197            .filter(|manifest| manifest.id == node_type);
198        if let Some(manifest) = manifests.next() {
199            if manifests.next().is_some() {
200                return Err(NodeError::InvalidCapability(format!(
201                    "guard capability '{node_type}' has multiple versions; register an exact implementation"
202                )));
203            }
204            if manifest.kind != crate::CapabilityKind::Guard {
205                return Err(NodeError::InvalidCapability(format!(
206                    "capability '{node_type}' manifest is not a guard"
207                )));
208            }
209            if manifest.guard_kind != Some(kind) {
210                return Err(NodeError::InvalidCapability(format!(
211                    "guard capability '{node_type}' implementation guard {:?} does not match manifest guard {:?}",
212                    kind, manifest.guard_kind
213                )));
214            }
215        }
216        self.guard_kinds.insert(node_type, kind);
217        Ok(())
218    }
219
220    /// Guard role of a node type, if registered as a guard.
221    pub fn guard_kind(&self, node_type: &str) -> Option<GuardKind> {
222        self.capability(node_type)
223            .and_then(|manifest| manifest.guard_kind)
224            .or_else(|| self.guard_kinds.get(node_type).copied())
225    }
226
227    /// Whether the node type is a registered guard.
228    pub fn is_side_effect_guard(&self, node_type: &str) -> bool {
229        self.guard_kind(node_type).is_some()
230    }
231
232    /// Whether the node type is an ingress source.
233    pub fn is_ingress(&self, node_type: &str) -> bool {
234        self.ingress.contains(node_type) || node_type.starts_with("ingress.")
235    }
236
237    /// Whether the node type can be compiled as a step.
238    pub fn is_step(&self, node_type: &str) -> bool {
239        self.steps.contains_key(node_type)
240            || self
241                .capabilities
242                .values()
243                .any(|manifest| manifest.id == node_type)
244    }
245
246    /// Build a step node instance from a spec node's type + config.
247    pub fn build_step(
248        &self,
249        node_type: &str,
250        config: &Value,
251    ) -> Result<Box<dyn StepNode>, NodeError> {
252        let factory = self
253            .steps
254            .get(node_type)
255            .ok_or_else(|| NodeError::UnknownType(node_type.to_string()))?;
256        factory(config)
257    }
258
259    /// Registered step node types.
260    pub fn known_step_types(&self) -> impl Iterator<Item = &str> {
261        self.steps.keys().map(|s| s.as_str())
262    }
263
264    /// Attach a config schema to a node type.
265    pub fn register_schema(&mut self, node_type: impl Into<String>, schema: NodeSchema) {
266        self.schemas.insert(node_type.into(), schema);
267    }
268
269    /// Config schema of a node type.
270    pub fn schema(&self, node_type: &str) -> Option<&NodeSchema> {
271        self.schemas.get(node_type)
272    }
273
274    /// Stable node configuration schemas for authoring catalogs.
275    pub fn authoring_schemas(&self) -> Vec<(String, Value)> {
276        let mut entries = self
277            .schemas
278            .iter()
279            .map(|(id, schema)| (id.clone(), schema.to_json_schema()))
280            .collect::<Vec<_>>();
281        entries.sort_by(|left, right| left.0.cmp(&right.0));
282        entries
283    }
284
285    /// Register the immutable contract for a trigger, expression, guard or action.
286    pub fn register_capability(&mut self, manifest: CapabilityManifest) -> Result<(), NodeError> {
287        manifest
288            .validate()
289            .map_err(|error| NodeError::InvalidCapability(error.to_string()))?;
290        let pin = CapabilityPin {
291            id: manifest.id.clone(),
292            contract_version: manifest.contract_version.clone(),
293            content_digest: manifest.content_digest.clone(),
294        };
295        if self.capabilities.contains_key(&pin) {
296            return Err(NodeError::DuplicateCapability(manifest.id));
297        }
298        if let Some(guard) = self.guard_kinds.get(&manifest.id).copied() {
299            if self
300                .capabilities
301                .values()
302                .any(|registered| registered.id == manifest.id)
303            {
304                return Err(NodeError::InvalidCapability(format!(
305                    "guard capability '{}' has multiple versions; register an exact implementation",
306                    manifest.id
307                )));
308            }
309            if manifest.kind != crate::CapabilityKind::Guard {
310                return Err(NodeError::InvalidCapability(format!(
311                    "capability '{}' manifest is not a guard",
312                    manifest.id
313                )));
314            }
315            if manifest.guard_kind != Some(guard) {
316                return Err(NodeError::InvalidCapability(format!(
317                    "guard capability '{}' manifest guard {:?} does not match implementation guard {:?}",
318                    manifest.id, manifest.guard_kind, guard
319                )));
320            }
321        }
322        self.capabilities.insert(pin, manifest);
323        Ok(())
324    }
325
326    /// Bind executable behavior and optional authoring/guard metadata to one immutable capability pin.
327    pub fn register_capability_implementation(
328        &mut self,
329        pin: CapabilityPin,
330        factory: StepFactory,
331        schema: Option<NodeSchema>,
332        guard: Option<GuardKind>,
333        fan_out: bool,
334    ) -> Result<(), NodeError> {
335        let manifest = self.capabilities.get(&pin).ok_or_else(|| {
336            NodeError::InvalidCapability(format!(
337                "capability '{}' implementation has no registered manifest at {} ({})",
338                pin.id, pin.contract_version, pin.content_digest
339            ))
340        })?;
341        if guard != manifest.guard_kind {
342            return Err(NodeError::InvalidCapability(format!(
343                "capability '{}' implementation guard {:?} does not match manifest guard {:?}",
344                pin.id, guard, manifest.guard_kind
345            )));
346        }
347        if self.capability_steps.contains_key(&pin) {
348            return Err(NodeError::DuplicateCapability(pin.id));
349        }
350        self.capability_steps.insert(pin.clone(), factory);
351        if let Some(schema) = schema {
352            self.capability_schemas.insert(pin.clone(), schema);
353        }
354        if let Some(guard) = guard {
355            self.capability_guards.insert(pin.clone(), guard);
356        }
357        if fan_out {
358            self.capability_fan_out.insert(pin);
359        }
360        Ok(())
361    }
362
363    /// Registered manifest by capability id when exactly one version is selected.
364    pub fn capability(&self, id: &str) -> Option<&CapabilityManifest> {
365        let mut matches = self
366            .capabilities
367            .values()
368            .filter(|manifest| manifest.id == id);
369        let manifest = matches.next()?;
370        matches.next().is_none().then_some(manifest)
371    }
372
373    /// Registered manifest by its immutable pin.
374    pub fn capability_by_pin(&self, pin: &CapabilityPin) -> Option<&CapabilityManifest> {
375        self.capabilities.get(pin)
376    }
377
378    /// Clone this registry with only the exact capability versions pinned by one revision.
379    pub fn for_capability_pins(&self, pins: &[CapabilityPin]) -> Result<Self, NodeError> {
380        let mut selected = self.clone();
381        selected.capabilities.clear();
382        selected
383            .fan_out
384            .retain(|node_type| !pins.iter().any(|pin| pin.id == *node_type));
385        for pin in pins {
386            selected.guard_kinds.remove(&pin.id);
387            let manifest = self.capability_by_pin(pin).ok_or_else(|| {
388                NodeError::InvalidCapability(format!(
389                    "capability '{}' is unavailable at {} ({})",
390                    pin.id, pin.contract_version, pin.content_digest
391                ))
392            })?;
393            selected.capabilities.insert(pin.clone(), manifest.clone());
394            let versions = self
395                .capabilities
396                .keys()
397                .filter(|candidate| candidate.id == pin.id)
398                .count();
399            if let Some(factory) = self.capability_steps.get(pin) {
400                selected.steps.insert(pin.id.clone(), *factory);
401            } else if versions > 1 && self.steps.contains_key(&pin.id) {
402                return Err(NodeError::InvalidCapability(format!(
403                    "capability '{}' has multiple versions but no executable implementation for {} ({})",
404                    pin.id, pin.contract_version, pin.content_digest
405                )));
406            }
407            if let Some(schema) = self.capability_schemas.get(pin) {
408                selected.schemas.insert(pin.id.clone(), schema.clone());
409            } else if versions > 1 && self.schemas.contains_key(&pin.id) {
410                return Err(NodeError::InvalidCapability(format!(
411                    "capability '{}' has multiple versions but no authoring schema for {} ({})",
412                    pin.id, pin.contract_version, pin.content_digest
413                )));
414            }
415            if let Some(manifest_guard) = manifest.guard_kind {
416                let guard = match self.capability_guards.get(pin).copied() {
417                    Some(guard) => guard,
418                    None => match self.guard_kinds.get(&pin.id).copied() {
419                        Some(guard) if versions == 1 && guard == manifest_guard => guard,
420                        Some(guard) if versions == 1 => {
421                            return Err(NodeError::InvalidCapability(format!(
422                                "guard capability '{}' implementation guard {:?} does not match manifest guard {:?}",
423                                pin.id, guard, manifest_guard
424                            )));
425                        }
426                        Some(_) => {
427                            return Err(NodeError::InvalidCapability(format!(
428                                "guard capability '{}' has multiple versions but no exact guard implementation for {} ({})",
429                                pin.id, pin.contract_version, pin.content_digest
430                            )));
431                        }
432                        None if !self.steps.contains_key(&pin.id) => manifest_guard,
433                        None => {
434                            return Err(NodeError::InvalidCapability(format!(
435                                "guard capability '{}' has no guard implementation for {} ({})",
436                                pin.id, pin.contract_version, pin.content_digest
437                            )));
438                        }
439                    },
440                };
441                selected.guard_kinds.insert(pin.id.clone(), guard);
442            } else if self.steps.contains_key(&pin.id) && self.guard_kinds.contains_key(&pin.id) {
443                return Err(NodeError::InvalidCapability(format!(
444                    "capability '{}' implementation declares a guard role but its manifest is not a guard",
445                    pin.id
446                )));
447            }
448            if self.capability_fan_out.contains(pin) {
449                selected.fan_out.insert(pin.id.clone());
450            }
451        }
452        Ok(selected)
453    }
454
455    /// Every registered manifest.
456    pub fn capability_manifests(&self) -> impl Iterator<Item = &CapabilityManifest> {
457        self.capabilities.values()
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::{CapabilityKind, Effect, IdempotencyMode};
465
466    struct Noop;
467
468    #[async_trait::async_trait]
469    impl StepNode for Noop {
470        async fn process(
471            &self,
472            event: &crate::Event,
473            _ctx: &crate::WorkflowContext,
474        ) -> crate::StepResult {
475            crate::StepResult::Pass(event.clone())
476        }
477    }
478
479    fn noop(_: &Value) -> Result<Box<dyn StepNode>, NodeError> {
480        Ok(Box::new(Noop))
481    }
482
483    #[test]
484    fn capability_versions_are_indexed_and_selected_by_full_pin() {
485        let manifest = |version: &str, digest: &str| {
486            CapabilityManifest::action(
487                "action.versioned",
488                version,
489                digest,
490                Effect::ExternalWrite,
491                IdempotencyMode::Native,
492                true,
493            )
494        };
495        let v1 = manifest("1", "digest-v1");
496        let v2 = manifest("2", "digest-v2");
497        let pin = CapabilityPin {
498            id: v1.id.clone(),
499            contract_version: v1.contract_version.clone(),
500            content_digest: v1.content_digest.clone(),
501        };
502        let mut registry = NodeRegistry::empty();
503        registry.register_capability(v1.clone()).unwrap();
504        registry.register_capability(v2).unwrap();
505
506        assert!(registry.capability("action.versioned").is_none());
507        assert_eq!(registry.capability_by_pin(&pin), Some(&v1));
508        let selected = registry.for_capability_pins(&[pin]).unwrap();
509        assert_eq!(selected.capability("action.versioned"), Some(&v1));
510    }
511
512    #[test]
513    fn versioned_capability_selects_exact_factory_schema_and_guard() {
514        let manifest = |version: &str, digest: &str| {
515            let mut manifest = CapabilityManifest::action(
516                "guard.versioned",
517                version,
518                digest,
519                Effect::Pure,
520                IdempotencyMode::Native,
521                true,
522            );
523            manifest.kind = CapabilityKind::Guard;
524            manifest.guard_kind = Some(GuardKind::Authorization);
525            manifest
526        };
527        let v1 = manifest("1", "digest-v1");
528        let v2 = manifest("2", "digest-v2");
529        let pin = CapabilityPin {
530            id: v1.id.clone(),
531            contract_version: v1.contract_version.clone(),
532            content_digest: v1.content_digest.clone(),
533        };
534        let schema = NodeSchema {
535            fields: vec![FieldSpec::required("approved", FieldType::Bool)],
536        };
537        let mut registry = NodeRegistry::empty();
538        registry.register_capability(v1).unwrap();
539        registry.register_capability(v2).unwrap();
540        let mismatch = registry
541            .register_capability_implementation(
542                pin.clone(),
543                noop,
544                None,
545                Some(GuardKind::Freshness),
546                false,
547            )
548            .unwrap_err();
549        assert!(matches!(mismatch, NodeError::InvalidCapability(_)));
550        registry
551            .register_capability_implementation(
552                pin.clone(),
553                noop,
554                Some(schema.clone()),
555                Some(GuardKind::Authorization),
556                false,
557            )
558            .unwrap();
559
560        let selected = registry.for_capability_pins(&[pin]).unwrap();
561        assert_eq!(selected.schema("guard.versioned"), Some(&schema));
562        assert_eq!(
563            selected.guard_kind("guard.versioned"),
564            Some(GuardKind::Authorization)
565        );
566        assert!(selected.build_step("guard.versioned", &Value::Null).is_ok());
567    }
568
569    #[test]
570    fn versioned_capability_selects_fan_out_by_full_pin() {
571        let manifest = |version: &str, digest: &str| {
572            CapabilityManifest::action(
573                "transform.versioned",
574                version,
575                digest,
576                Effect::Pure,
577                IdempotencyMode::Native,
578                true,
579            )
580        };
581        let v1 = manifest("1", "digest-v1");
582        let v2 = manifest("2", "digest-v2");
583        let pin = |manifest: &CapabilityManifest| CapabilityPin {
584            id: manifest.id.clone(),
585            contract_version: manifest.contract_version.clone(),
586            content_digest: manifest.content_digest.clone(),
587        };
588        let mut registry = NodeRegistry::empty();
589        registry.register_capability(v1.clone()).unwrap();
590        registry.register_capability(v2.clone()).unwrap();
591        registry
592            .register_capability_implementation(pin(&v1), noop, None, None, true)
593            .unwrap();
594        registry
595            .register_capability_implementation(pin(&v2), noop, None, None, false)
596            .unwrap();
597
598        assert!(registry
599            .for_capability_pins(&[pin(&v1)])
600            .unwrap()
601            .is_fan_out_capable("transform.versioned"));
602        assert!(!registry
603            .for_capability_pins(&[pin(&v2)])
604            .unwrap()
605            .is_fan_out_capable("transform.versioned"));
606    }
607
608    #[test]
609    fn legacy_guard_registration_must_match_the_manifest() {
610        let mut manifest = CapabilityManifest::action(
611            "guard.legacy",
612            "1",
613            "digest",
614            Effect::Pure,
615            IdempotencyMode::None,
616            false,
617        );
618        manifest.kind = CapabilityKind::Guard;
619        manifest.guard_kind = Some(GuardKind::Authorization);
620        let pin = CapabilityPin {
621            id: manifest.id.clone(),
622            contract_version: manifest.contract_version.clone(),
623            content_digest: manifest.content_digest.clone(),
624        };
625        let mut persisted = NodeRegistry::empty();
626        persisted.register_capability(manifest.clone()).unwrap();
627        assert_eq!(
628            persisted
629                .for_capability_pins(std::slice::from_ref(&pin))
630                .unwrap()
631                .guard_kind(&manifest.id),
632            manifest.guard_kind
633        );
634
635        let mut legacy = persisted;
636        legacy.register_step(&manifest.id, noop);
637        assert!(matches!(
638            legacy.register_guard(&manifest.id, GuardKind::Freshness),
639            Err(NodeError::InvalidCapability(reason)) if reason.contains("does not match")
640        ));
641        legacy
642            .register_guard(&manifest.id, GuardKind::Authorization)
643            .unwrap();
644        assert_eq!(
645            legacy
646                .for_capability_pins(&[pin])
647                .unwrap()
648                .guard_kind(&manifest.id),
649            manifest.guard_kind
650        );
651
652        let action = CapabilityManifest::action(
653            "action.legacy",
654            "1",
655            "digest",
656            Effect::Pure,
657            IdempotencyMode::None,
658            false,
659        );
660        let action_pin = CapabilityPin {
661            id: action.id.clone(),
662            contract_version: action.contract_version.clone(),
663            content_digest: action.content_digest.clone(),
664        };
665        let mut non_guard = NodeRegistry::empty();
666        non_guard.register_capability(action.clone()).unwrap();
667        assert!(non_guard
668            .for_capability_pins(std::slice::from_ref(&action_pin))
669            .is_ok());
670        non_guard.register_step(&action.id, noop);
671        assert!(matches!(
672            non_guard.register_guard(&action.id, GuardKind::Authorization),
673            Err(NodeError::InvalidCapability(reason)) if reason.contains("manifest is not a guard")
674        ));
675        assert!(non_guard
676            .for_capability_pins(&[action_pin])
677            .unwrap()
678            .guard_kind(&action.id)
679            .is_none());
680    }
681
682    #[test]
683    fn manifest_registration_must_match_a_legacy_guard() {
684        let manifest = |version: &str, kind| {
685            let mut manifest = CapabilityManifest::action(
686                "guard.reverse",
687                version,
688                format!("digest-{version}"),
689                Effect::Pure,
690                IdempotencyMode::None,
691                false,
692            );
693            manifest.kind = CapabilityKind::Guard;
694            manifest.guard_kind = Some(kind);
695            manifest
696        };
697        let mut registry = NodeRegistry::empty();
698        registry
699            .register_guard("guard.reverse", GuardKind::Authorization)
700            .unwrap();
701        assert!(matches!(
702            registry.register_capability(manifest("1", GuardKind::Freshness)),
703            Err(NodeError::InvalidCapability(reason)) if reason.contains("does not match")
704        ));
705        registry
706            .register_capability(manifest("1", GuardKind::Authorization))
707            .unwrap();
708        assert!(matches!(
709            registry.register_capability(manifest("2", GuardKind::Authorization)),
710            Err(NodeError::InvalidCapability(reason)) if reason.contains("multiple versions")
711        ));
712
713        let mut non_guard = NodeRegistry::empty();
714        non_guard
715            .register_guard("action.reverse", GuardKind::Authorization)
716            .unwrap();
717        assert!(matches!(
718            non_guard.register_capability(CapabilityManifest::action(
719                "action.reverse",
720                "1",
721                "digest",
722                Effect::Pure,
723                IdempotencyMode::None,
724                false,
725            )),
726            Err(NodeError::InvalidCapability(reason)) if reason.contains("manifest is not a guard")
727        ));
728    }
729}