1use super::knowledge::{KnowledgeLimitsV1, PluginKnowledgeSchema, validate_schema_set};
2use super::records::DomainRecordSchema;
3use super::{
4 BoundaryPhase, BoundarySystemContract, BoundarySystemHandler, BoundaryWriteStage,
5 CORE_STATE_NAMESPACE, CanwuError, CommandContext, EntityRef, ErrorCode, IngressClass,
6 PluginIngressDescriptor, RandomStreamKey, ReservationRef, SchemaRegistry, SimDuration,
7 SimEvent, SimulationView, StateKey, StateVisibility, SystemCadence, SystemContract, TypeSchema,
8 boundary_write_stage, canonical_text, invalid_snapshot, invalid_snapshot_error,
9 is_canonical_hash, is_domain_record_state, knowledge, records, validate_type_schema,
10};
11use canwu_event::EventAudience;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::{BTreeMap, BTreeSet};
15
16#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum PayloadValueType {
19 Null,
20 Boolean,
21 Integer,
22 String,
23 Object,
24 Array,
25}
26
27impl PayloadValueType {
28 fn matches(&self, value: &Value) -> bool {
29 match self {
30 Self::Null => value.is_null(),
31 Self::Boolean => value.is_boolean(),
32 Self::Integer => value.as_i64().is_some() || value.as_u64().is_some(),
33 Self::String => value.is_string(),
34 Self::Object => value.is_object(),
35 Self::Array => value.is_array(),
36 }
37 }
38}
39
40#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
41pub struct PayloadProperty {
42 pub value_type: PayloadValueType,
43 pub required: bool,
44}
45
46#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
47#[serde(tag = "type", rename_all = "snake_case")]
48pub enum PayloadSchema {
49 Any,
50 Null,
51 Boolean,
52 Integer,
53 String,
54 Object {
55 properties: BTreeMap<String, PayloadProperty>,
56 allow_additional: bool,
57 },
58}
59
60impl PayloadSchema {
61 pub(super) fn validate(&self, value: &Value) -> Result<(), CanwuError> {
62 let scalar_matches = match self {
63 Self::Any => return Ok(()),
64 Self::Null => value.is_null(),
65 Self::Boolean => value.is_boolean(),
66 Self::Integer => value.as_i64().is_some() || value.as_u64().is_some(),
67 Self::String => value.is_string(),
68 Self::Object {
69 properties,
70 allow_additional,
71 } => {
72 let Some(object) = value.as_object() else {
73 return Err(CanwuError::new(
74 ErrorCode::InvalidPayload,
75 "plugin command payload must be an object",
76 ));
77 };
78 for (name, property) in properties {
79 match object.get(name) {
80 Some(field) if !property.value_type.matches(field) => {
81 return Err(CanwuError::new(
82 ErrorCode::InvalidPayload,
83 format!("payload field {name} has the wrong type"),
84 ));
85 }
86 None if property.required => {
87 return Err(CanwuError::new(
88 ErrorCode::InvalidPayload,
89 format!("payload field {name} is required"),
90 ));
91 }
92 Some(_) | None => {}
93 }
94 }
95 if !allow_additional && object.keys().any(|name| !properties.contains_key(name)) {
96 return Err(CanwuError::new(
97 ErrorCode::InvalidPayload,
98 "plugin command payload contains an undeclared field",
99 ));
100 }
101 return Ok(());
102 }
103 };
104 if scalar_matches {
105 Ok(())
106 } else {
107 Err(CanwuError::new(
108 ErrorCode::InvalidPayload,
109 "plugin command payload does not match its declared schema",
110 ))
111 }
112 }
113}
114
115#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
116pub struct PluginActionDescriptor {
117 pub name: String,
118 pub description: String,
119 pub payload_schema: PayloadSchema,
120 pub reads: Vec<StateKey>,
121 pub writes: Vec<StateKey>,
122}
123
124#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
125pub struct PluginDescriptor {
126 pub name: String,
127 #[serde(default)]
128 pub version: String,
129 #[serde(default)]
130 pub semantic_hash: String,
131 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
134 pub event_audiences: BTreeMap<String, EventAudience>,
135 pub systems: Vec<SystemContract>,
136 #[serde(default)]
137 pub boundary_systems: Vec<BoundarySystemContract>,
138 pub commands: Vec<PluginActionDescriptor>,
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub ingress: Vec<PluginIngressDescriptor>,
141 pub schema_types: Vec<String>,
142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub record_schemas: Vec<DomainRecordSchema>,
144 #[serde(default, skip_serializing_if = "Vec::is_empty")]
145 pub knowledge_schemas: Vec<PluginKnowledgeSchema>,
146}
147
148#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
149pub struct PluginComponentRecord {
150 pub plugin: String,
151 pub state: StateKey,
152 pub entity: EntityRef,
153 pub component: String,
154 pub value: Value,
155}
156
157#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
158pub(super) struct PluginComponentKey {
159 pub(super) plugin: String,
160 pub(super) state: StateKey,
161 pub(super) entity: EntityRef,
162 pub(super) component: String,
163}
164
165#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
166#[serde(tag = "type", rename_all = "snake_case")]
167pub enum SystemDirective {
168 SetComponent {
169 state: StateKey,
170 entity: EntityRef,
171 component: String,
172 value: Value,
173 summary: String,
174 },
175 Emit {
176 event_type: String,
177 summary: String,
178 affected: Vec<EntityRef>,
179 },
180 Schedule {
181 after: SimDuration,
182 directive: Box<SystemDirective>,
183 },
184 EnqueuePluginIngress {
187 after: SimDuration,
188 packet_type: String,
189 priority: i32,
190 payload: Value,
191 affected: Vec<EntityRef>,
192 },
193}
194
195pub type SimulationSystemHandler =
202 fn(&SimulationView<'_>, &SimEvent) -> Result<Vec<SystemDirective>, CanwuError>;
203
204pub type PluginCommandHandler =
205 fn(&SimulationView<'_>, &CommandContext, &Value) -> Result<Vec<SystemDirective>, CanwuError>;
206
207pub trait SimulationPlugin {
210 fn name(&self) -> &str;
211 fn version(&self) -> &str;
213 fn semantic_hash(&self) -> &str;
218 fn register(&self, registrar: &mut PluginRegistrar<'_>) -> Result<(), CanwuError>;
219}
220
221#[derive(Clone, Default)]
222pub struct PluginRegistry {
223 pub(super) descriptors: BTreeMap<String, PluginDescriptor>,
224 pub(super) active_plugins: BTreeSet<String>,
225 pub(super) systems: Vec<RegisteredSystem>,
226 pub(super) boundary_systems: Vec<RegisteredBoundarySystem>,
227 pub(super) commands: BTreeMap<(String, String), RegisteredCommand>,
228 pub(super) ingress: BTreeMap<(String, String), PluginIngressDescriptor>,
229 pub(super) state_owners: BTreeMap<StateKey, String>,
230 pub(super) immediate_write_states: BTreeMap<StateKey, String>,
231 pub(super) boundary_writers: BTreeMap<(BoundaryWriteStage, StateKey), (String, String)>,
232 pub(super) reservation_offerers: BTreeMap<StateKey, (String, String)>,
233 pub(super) random_stream_owners: BTreeMap<RandomStreamKey, (String, String)>,
234 pub(super) record_schemas: records::DomainRecordSchemas,
235 pub(super) knowledge_schemas: knowledge::KnowledgeSchemas,
236 pub(super) knowledge_kind_owners: knowledge::KnowledgeKindOwners,
237}
238
239#[derive(Clone)]
240pub(super) struct RegisteredSystem {
241 pub(super) plugin: String,
242 pub(super) contract: SystemContract,
243 pub(super) handler: SimulationSystemHandler,
244}
245
246#[derive(Clone)]
247pub(super) struct RegisteredBoundarySystem {
248 pub(super) plugin: String,
249 pub(super) contract: BoundarySystemContract,
250 pub(super) handler: BoundarySystemHandler,
251}
252
253#[derive(Clone)]
254pub(super) struct RegisteredCommand {
255 pub(super) descriptor: PluginActionDescriptor,
256 pub(super) handler: PluginCommandHandler,
257}
258
259pub struct PluginRegistrar<'a> {
260 pub(super) plugin: String,
261 pub(super) registry: &'a mut PluginRegistry,
262 pub(super) schema: &'a mut SchemaRegistry,
263}
264
265impl PluginRegistrar<'_> {
266 pub fn register_record_schema(
267 &mut self,
268 mut schema: DomainRecordSchema,
269 ) -> Result<(), CanwuError> {
270 schema.canonicalize();
271 schema.validate().map_err(|error| {
272 CanwuError::new(
273 ErrorCode::InvalidPluginRegistration,
274 format!("invalid domain record schema: {error}"),
275 )
276 })?;
277 let state = schema.state_key();
278 if state.namespace == CORE_STATE_NAMESPACE {
279 return Err(CanwuError::new(
280 ErrorCode::InvalidPluginRegistration,
281 "plugins cannot register domain record kinds in the core namespace",
282 ));
283 }
284 if self
285 .registry
286 .descriptors
287 .get(&self.plugin)
288 .is_some_and(|descriptor| {
289 descriptor
290 .record_schemas
291 .iter()
292 .any(|candidate| candidate.kind == schema.kind)
293 })
294 {
295 return Err(CanwuError::new(
296 ErrorCode::DuplicateDomainRecordKind,
297 format!(
298 "plugin {} registered record kind {} twice",
299 self.plugin, schema.kind
300 ),
301 ));
302 }
303 if let Some((owner, existing)) = self.registry.record_schemas.get(&schema.kind) {
304 if owner != &self.plugin {
305 return Err(CanwuError::new(
306 ErrorCode::DuplicateDomainRecordKind,
307 format!(
308 "domain record kind {} is already owned by plugin {owner}",
309 schema.kind
310 ),
311 ));
312 }
313 if existing != &schema {
314 return Err(CanwuError::new(
315 ErrorCode::PluginManifestMismatch,
316 format!(
317 "plugin {} changed the stored schema for domain record kind {}",
318 self.plugin, schema.kind
319 ),
320 ));
321 }
322 }
323 let mut candidate = self.registry.clone();
324 if candidate.immediate_write_states.contains_key(&state) {
325 return Err(CanwuError::new(
326 ErrorCode::InvalidPluginRegistration,
327 format!(
328 "domain record kind {} is already exposed as immediate component state",
329 schema.kind
330 ),
331 ));
332 }
333 register_state_owners(
334 &mut candidate.state_owners,
335 &self.plugin,
336 std::slice::from_ref(&state),
337 )?;
338 candidate
339 .record_schemas
340 .insert(schema.kind.clone(), (self.plugin.clone(), schema.clone()));
341 let descriptor = candidate
342 .descriptors
343 .entry(self.plugin.clone())
344 .or_default();
345 descriptor.name.clone_from(&self.plugin);
346 descriptor.record_schemas.push(schema);
347 descriptor
348 .record_schemas
349 .sort_by(|left, right| left.kind.cmp(&right.kind));
350 *self.registry = candidate;
351 Ok(())
352 }
353
354 pub fn register_knowledge_schema(
355 &mut self,
356 mut schema: PluginKnowledgeSchema,
357 ) -> Result<(), CanwuError> {
358 schema.canonicalize();
359 schema.validate().map_err(|error| {
360 CanwuError::new(
361 ErrorCode::InvalidPluginRegistration,
362 format!("invalid knowledge schema: {error}"),
363 )
364 })?;
365 let current_count = self
366 .registry
367 .descriptors
368 .get(&self.plugin)
369 .map_or(0, |descriptor| descriptor.knowledge_schemas.len());
370 if current_count >= KnowledgeLimitsV1::CURRENT.schemas_per_plugin {
371 return Err(CanwuError::new(
372 ErrorCode::InvalidPluginRegistration,
373 "plugin knowledge schema limit exceeded",
374 ));
375 }
376 if self
377 .registry
378 .descriptors
379 .get(&self.plugin)
380 .is_some_and(|descriptor| {
381 descriptor
382 .knowledge_schemas
383 .iter()
384 .any(|candidate| candidate.id == schema.id)
385 })
386 {
387 return Err(CanwuError::new(
388 ErrorCode::InvalidPluginRegistration,
389 format!(
390 "plugin {} registered knowledge schema {:?} twice",
391 self.plugin, schema.id
392 ),
393 ));
394 }
395 if let Some(owner) = self.registry.knowledge_kind_owners.get(&schema.id.kind)
396 && owner != &self.plugin
397 {
398 return Err(CanwuError::new(
399 ErrorCode::InvalidPluginRegistration,
400 format!(
401 "knowledge kind {:?} is already owned by plugin {owner}",
402 schema.id.kind
403 ),
404 ));
405 }
406 if let Some((owner, existing)) = self.registry.knowledge_schemas.get(&schema.id) {
407 if owner != &self.plugin {
408 return Err(CanwuError::new(
409 ErrorCode::InvalidPluginRegistration,
410 format!(
411 "knowledge schema {:?} is already owned by plugin {owner}",
412 schema.id
413 ),
414 ));
415 }
416 if existing != &schema {
417 return Err(CanwuError::new(
418 ErrorCode::PluginManifestMismatch,
419 format!(
420 "plugin {} changed the stored knowledge schema {:?}",
421 self.plugin, schema.id
422 ),
423 ));
424 }
425 }
426 if schema.writable
427 && self
428 .registry
429 .knowledge_schemas
430 .values()
431 .any(|(owner, existing)| {
432 owner == &self.plugin
433 && existing.id != schema.id
434 && existing.id.kind == schema.id.kind
435 && existing.writable
436 })
437 {
438 return Err(CanwuError::new(
439 ErrorCode::InvalidPluginRegistration,
440 format!(
441 "knowledge kind {:?} already has a writable version",
442 schema.id.kind
443 ),
444 ));
445 }
446 let mut candidate = self.registry.clone();
447 candidate
448 .knowledge_kind_owners
449 .entry(schema.id.kind.clone())
450 .or_insert_with(|| self.plugin.clone());
451 candidate
452 .knowledge_schemas
453 .insert(schema.id.clone(), (self.plugin.clone(), schema.clone()));
454 let descriptor = candidate
455 .descriptors
456 .entry(self.plugin.clone())
457 .or_default();
458 descriptor.name.clone_from(&self.plugin);
459 descriptor.knowledge_schemas.push(schema);
460 descriptor
461 .knowledge_schemas
462 .sort_by(|left, right| left.id.cmp(&right.id));
463 *self.registry = candidate;
464 Ok(())
465 }
466
467 pub fn register_schema(&mut self, schema: TypeSchema) -> Result<(), CanwuError> {
468 validate_type_schema(&schema)?;
469 let type_name = schema.type_name.clone();
470 let mut candidate_schema = self.schema.clone();
471 let mut candidate_registry = self.registry.clone();
472 if let Some(existing) = candidate_schema.get(&type_name) {
473 if existing != &schema {
474 return Err(CanwuError::new(
475 ErrorCode::InvalidPluginRegistration,
476 format!(
477 "schema type {type_name} is already registered with a different definition"
478 ),
479 ));
480 }
481 } else {
482 candidate_schema.register(schema);
483 }
484 let descriptor = candidate_registry
485 .descriptors
486 .entry(self.plugin.clone())
487 .or_default();
488 if descriptor.schema_types.contains(&type_name) {
489 return Err(CanwuError::new(
490 ErrorCode::InvalidPluginRegistration,
491 format!(
492 "plugin {} registered schema type {} more than once",
493 self.plugin, type_name
494 ),
495 ));
496 }
497 descriptor.name.clone_from(&self.plugin);
498 descriptor.schema_types.push(type_name);
499 descriptor.schema_types.sort();
500 *self.schema = candidate_schema;
501 *self.registry = candidate_registry;
502 Ok(())
503 }
504
505 pub fn register_event_audience(
511 &mut self,
512 event_type: impl Into<String>,
513 audience: EventAudience,
514 ) -> Result<(), CanwuError> {
515 let event_type = event_type.into();
516 validate_event_audience_name(&event_type)?;
517 validate_event_audience(&audience)?;
518 let mut candidate = self.registry.clone();
519 let descriptor = candidate
520 .descriptors
521 .entry(self.plugin.clone())
522 .or_default();
523 descriptor.name.clone_from(&self.plugin);
524 if descriptor
525 .event_audiences
526 .insert(event_type.clone(), audience)
527 .is_some()
528 {
529 return Err(CanwuError::new(
530 ErrorCode::InvalidPluginRegistration,
531 format!(
532 "plugin {} already declared event audience for {event_type}",
533 self.plugin
534 ),
535 ));
536 }
537 *self.registry = candidate;
538 Ok(())
539 }
540
541 pub fn register_system(
542 &mut self,
543 mut contract: SystemContract,
544 handler: SimulationSystemHandler,
545 ) -> Result<(), CanwuError> {
546 validate_system_contract(&self.plugin, &mut contract)?;
550 if self
551 .registry
552 .descriptors
553 .get(&self.plugin)
554 .is_some_and(|descriptor| {
555 descriptor
556 .systems
557 .iter()
558 .any(|candidate| candidate.name == contract.name)
559 || descriptor
560 .boundary_systems
561 .iter()
562 .any(|candidate| candidate.name == contract.name)
563 })
564 {
565 return Err(CanwuError::new(
566 ErrorCode::DuplicatePluginSystem,
567 format!(
568 "plugin {} already registered system {}",
569 self.plugin, contract.name
570 ),
571 ));
572 }
573 let mut candidate = self.registry.clone();
574 if contract
575 .writes
576 .iter()
577 .any(|state| is_domain_record_state(&candidate.record_schemas, state))
578 {
579 return Err(CanwuError::new(
580 ErrorCode::InvalidPluginRegistration,
581 "domain record kinds can only be mutated by phased boundary systems",
582 ));
583 }
584 register_state_owners(&mut candidate.state_owners, &self.plugin, &contract.writes)?;
585 register_immediate_write_states(
586 &mut candidate.immediate_write_states,
587 &candidate.boundary_writers,
588 &self.plugin,
589 &contract.writes,
590 )?;
591 {
592 let descriptor = candidate
593 .descriptors
594 .entry(self.plugin.clone())
595 .or_default();
596 descriptor.name.clone_from(&self.plugin);
597 descriptor.systems.push(contract.clone());
598 descriptor
599 .systems
600 .sort_by(|left, right| (left.phase, &left.name).cmp(&(right.phase, &right.name)));
601 }
602 candidate.systems.push(RegisteredSystem {
603 plugin: self.plugin.clone(),
604 contract,
605 handler,
606 });
607 candidate.systems.sort_by(|left, right| {
608 (left.contract.phase, &left.plugin, &left.contract.name).cmp(&(
609 right.contract.phase,
610 &right.plugin,
611 &right.contract.name,
612 ))
613 });
614 *self.registry = candidate;
615 Ok(())
616 }
617
618 pub fn register_boundary_system(
619 &mut self,
620 mut contract: BoundarySystemContract,
621 handler: BoundarySystemHandler,
622 ) -> Result<(), CanwuError> {
623 validate_boundary_system_contract(&mut contract)?;
624 validate_knowledge_write_grants(&self.plugin, &contract, &self.registry.knowledge_schemas)?;
625 if self
626 .registry
627 .descriptors
628 .get(&self.plugin)
629 .is_some_and(|descriptor| {
630 descriptor
631 .systems
632 .iter()
633 .any(|candidate| candidate.name == contract.name)
634 || descriptor
635 .boundary_systems
636 .iter()
637 .any(|candidate| candidate.name == contract.name)
638 })
639 {
640 return Err(CanwuError::new(
641 ErrorCode::DuplicatePluginSystem,
642 format!(
643 "plugin {} already registered system {}",
644 self.plugin, contract.name
645 ),
646 ));
647 }
648 let mut owned_state = contract.writes.clone();
649 owned_state.extend(contract.reservation_offers.iter().cloned());
650 owned_state.sort();
651 owned_state.dedup();
652 let mut candidate = self.registry.clone();
653 register_state_owners(&mut candidate.state_owners, &self.plugin, &owned_state)?;
654 register_boundary_writers(
655 &mut candidate.boundary_writers,
656 &candidate.immediate_write_states,
657 &self.plugin,
658 &contract.name,
659 contract.phase,
660 &contract.writes,
661 )?;
662 register_reservation_offerers(
663 &mut candidate.reservation_offerers,
664 &self.plugin,
665 &contract.name,
666 &contract.reservation_offers,
667 )?;
668 register_random_streams(
669 &mut candidate.random_stream_owners,
670 &self.plugin,
671 &contract.name,
672 &contract.random_streams,
673 )?;
674 {
675 let descriptor = candidate
676 .descriptors
677 .entry(self.plugin.clone())
678 .or_default();
679 descriptor.name.clone_from(&self.plugin);
680 descriptor.boundary_systems.push(contract.clone());
681 descriptor
682 .boundary_systems
683 .sort_by(|left, right| (left.phase, &left.name).cmp(&(right.phase, &right.name)));
684 }
685 candidate.boundary_systems.push(RegisteredBoundarySystem {
686 plugin: self.plugin.clone(),
687 contract,
688 handler,
689 });
690 candidate.boundary_systems.sort_by(|left, right| {
691 (left.contract.phase, &left.plugin, &left.contract.name).cmp(&(
692 right.contract.phase,
693 &right.plugin,
694 &right.contract.name,
695 ))
696 });
697 *self.registry = candidate;
698 Ok(())
699 }
700
701 pub fn register_command(
702 &mut self,
703 mut descriptor: PluginActionDescriptor,
704 handler: PluginCommandHandler,
705 ) -> Result<(), CanwuError> {
706 validate_action_descriptor(&self.plugin, &mut descriptor)?;
707 let command_key = (self.plugin.clone(), descriptor.name.clone());
708 if self.registry.commands.contains_key(&command_key) {
709 return Err(CanwuError::new(
710 ErrorCode::DuplicatePluginCommand,
711 format!(
712 "plugin {} already registered command {}",
713 self.plugin, descriptor.name
714 ),
715 ));
716 }
717 let mut candidate = self.registry.clone();
718 if descriptor
719 .writes
720 .iter()
721 .any(|state| is_domain_record_state(&candidate.record_schemas, state))
722 {
723 return Err(CanwuError::new(
724 ErrorCode::InvalidPluginRegistration,
725 "plugin commands cannot write domain record state directly",
726 ));
727 }
728 register_state_owners(
729 &mut candidate.state_owners,
730 &self.plugin,
731 &descriptor.writes,
732 )?;
733 register_immediate_write_states(
734 &mut candidate.immediate_write_states,
735 &candidate.boundary_writers,
736 &self.plugin,
737 &descriptor.writes,
738 )?;
739 {
740 let plugin_descriptor = candidate
741 .descriptors
742 .entry(self.plugin.clone())
743 .or_default();
744 plugin_descriptor.name.clone_from(&self.plugin);
745 plugin_descriptor.commands.push(descriptor.clone());
746 plugin_descriptor
747 .commands
748 .sort_by(|left, right| left.name.cmp(&right.name));
749 }
750 candidate.commands.insert(
751 command_key,
752 RegisteredCommand {
753 descriptor,
754 handler,
755 },
756 );
757 *self.registry = candidate;
758 Ok(())
759 }
760
761 pub fn register_ingress(
762 &mut self,
763 descriptor: PluginIngressDescriptor,
764 ) -> Result<(), CanwuError> {
765 validate_ingress_descriptor(&descriptor)?;
766 let key = (self.plugin.clone(), descriptor.name.clone());
767 if self
768 .registry
769 .descriptors
770 .get(&self.plugin)
771 .is_some_and(|plugin| {
772 plugin
773 .ingress
774 .iter()
775 .any(|candidate| candidate.name == descriptor.name)
776 })
777 {
778 return Err(CanwuError::new(
779 ErrorCode::DuplicatePluginIngress,
780 format!(
781 "plugin {} already registered ingress type {}",
782 self.plugin, descriptor.name
783 ),
784 ));
785 }
786 if self
787 .registry
788 .ingress
789 .get(&key)
790 .is_some_and(|existing| existing != &descriptor)
791 {
792 return Err(CanwuError::new(
793 ErrorCode::PluginManifestMismatch,
794 format!(
795 "plugin {} changed the stored ingress type {}",
796 self.plugin, descriptor.name
797 ),
798 ));
799 }
800 let mut candidate = self.registry.clone();
801 candidate.ingress.insert(key, descriptor.clone());
802 let plugin_descriptor = candidate
803 .descriptors
804 .entry(self.plugin.clone())
805 .or_default();
806 plugin_descriptor.name.clone_from(&self.plugin);
807 plugin_descriptor.ingress.push(descriptor);
808 plugin_descriptor
809 .ingress
810 .sort_by(|left, right| left.name.cmp(&right.name));
811 *self.registry = candidate;
812 Ok(())
813 }
814}
815
816impl PluginRegistry {
817 pub fn register<P: SimulationPlugin + ?Sized>(
818 &mut self,
819 plugin: &P,
820 schema: &mut SchemaRegistry,
821 ) -> Result<(), CanwuError> {
822 let raw_plugin_name = plugin.name();
823 let plugin_name = raw_plugin_name.trim();
824 if plugin_name.is_empty() || plugin_name != raw_plugin_name {
825 return Err(CanwuError::new(
826 ErrorCode::InvalidPluginRegistration,
827 "plugin name must be non-empty and have no surrounding whitespace",
828 ));
829 }
830 if self.active_plugins.contains(plugin_name) {
831 return Err(CanwuError::new(
832 ErrorCode::DuplicatePlugin,
833 format!("plugin {plugin_name} is already registered"),
834 ));
835 }
836 validate_plugin_identity(plugin_name, plugin.version(), plugin.semantic_hash())?;
837
838 let expected_descriptor = self.descriptors.get(plugin_name).cloned();
839 let mut candidate_registry = self.clone();
840 let mut candidate_schema = schema.clone();
841 candidate_registry.descriptors.insert(
842 plugin_name.to_owned(),
843 PluginDescriptor {
844 name: plugin_name.to_owned(),
845 version: plugin.version().to_owned(),
846 semantic_hash: plugin.semantic_hash().to_owned(),
847 ..PluginDescriptor::default()
848 },
849 );
850 let mut registrar = PluginRegistrar {
851 plugin: plugin_name.to_owned(),
852 registry: &mut candidate_registry,
853 schema: &mut candidate_schema,
854 };
855 plugin.register(&mut registrar)?;
856 validate_schema_set(
857 &candidate_registry.knowledge_schemas,
858 &candidate_registry.knowledge_kind_owners,
859 )
860 .map_err(|error| {
861 CanwuError::new(
862 ErrorCode::InvalidPluginRegistration,
863 format!("invalid knowledge schema set: {error}"),
864 )
865 })?;
866 let Some(generated_descriptor) = candidate_registry.descriptors.get(plugin_name) else {
867 return Err(CanwuError::new(
868 ErrorCode::InvalidPluginRegistration,
869 format!("plugin {plugin_name} did not produce a descriptor"),
870 ));
871 };
872 if let Some(expected) = expected_descriptor
873 && generated_descriptor != &expected
874 {
875 return Err(CanwuError::new(
876 ErrorCode::PluginManifestMismatch,
877 format!("plugin {plugin_name} registration does not match the snapshot manifest"),
878 ));
879 }
880 candidate_registry
881 .active_plugins
882 .insert(plugin_name.to_owned());
883 *self = candidate_registry;
884 *schema = candidate_schema;
885 Ok(())
886 }
887
888 pub fn descriptors(&self) -> impl Iterator<Item = &PluginDescriptor> {
889 self.descriptors.values()
890 }
891
892 pub(super) fn event_audience(&self, plugin: &str, event_type: &str) -> EventAudience {
893 self.descriptors
894 .get(plugin)
895 .and_then(|descriptor| descriptor.event_audiences.get(event_type))
896 .cloned()
897 .unwrap_or_default()
898 }
899
900 pub(super) fn from_descriptors(descriptors: Vec<PluginDescriptor>) -> Result<Self, CanwuError> {
901 let mut registry = Self {
902 descriptors: BTreeMap::new(),
903 active_plugins: BTreeSet::new(),
904 systems: Vec::new(),
905 boundary_systems: Vec::new(),
906 commands: BTreeMap::new(),
907 ingress: BTreeMap::new(),
908 state_owners: BTreeMap::new(),
909 immediate_write_states: BTreeMap::new(),
910 boundary_writers: BTreeMap::new(),
911 reservation_offerers: BTreeMap::new(),
912 random_stream_owners: BTreeMap::new(),
913 record_schemas: BTreeMap::new(),
914 knowledge_schemas: BTreeMap::new(),
915 knowledge_kind_owners: BTreeMap::new(),
916 };
917 let mut previous_plugin = None;
918 for mut descriptor in descriptors {
919 let plugin = descriptor.name.trim().to_owned();
920 if plugin.is_empty()
921 || descriptor.name != plugin
922 || descriptor.version.trim().is_empty()
923 || descriptor.version != descriptor.version.trim()
924 || !is_canonical_hash(&descriptor.semantic_hash)
925 || registry.descriptors.contains_key(&plugin)
926 || previous_plugin
927 .as_ref()
928 .is_some_and(|previous| previous >= &plugin)
929 {
930 return Err(CanwuError::new(
931 ErrorCode::InvalidSnapshot,
932 "snapshot contains an invalid, unversioned, or duplicate plugin descriptor",
933 ));
934 }
935 if descriptor
936 .record_schemas
937 .windows(2)
938 .any(|pair| pair[0].kind >= pair[1].kind)
939 {
940 return invalid_snapshot("plugin record schemas are not in canonical order");
941 }
942 for schema in &mut descriptor.record_schemas {
943 let original = schema.clone();
944 schema.canonicalize();
945 schema.validate().map_err(|error| {
946 invalid_snapshot_error(format!("invalid domain record schema: {error}"))
947 })?;
948 if *schema != original {
949 return invalid_snapshot(
950 "plugin record-schema declarations are not in canonical order",
951 );
952 }
953 let state = schema.state_key();
954 if state.namespace == CORE_STATE_NAMESPACE {
955 return invalid_snapshot(
956 "plugin record schemas cannot use the reserved core namespace",
957 );
958 }
959 if let Some((owner, _)) = registry.record_schemas.get(&schema.kind) {
960 return invalid_snapshot(format!(
961 "domain record kind {} is owned by both {owner} and {plugin}",
962 schema.kind
963 ));
964 }
965 register_state_owners(
966 &mut registry.state_owners,
967 &plugin,
968 std::slice::from_ref(&state),
969 )
970 .map_err(|error| {
971 invalid_snapshot_error(format!(
972 "invalid domain record state ownership descriptor: {error}"
973 ))
974 })?;
975 registry
976 .record_schemas
977 .insert(schema.kind.clone(), (plugin.clone(), schema.clone()));
978 }
979 if descriptor.knowledge_schemas.len() > KnowledgeLimitsV1::CURRENT.schemas_per_plugin
980 || descriptor
981 .knowledge_schemas
982 .windows(2)
983 .any(|pair| pair[0].id >= pair[1].id)
984 {
985 return invalid_snapshot(
986 "plugin knowledge schemas are not in canonical order or exceed their limit",
987 );
988 }
989 for schema in &mut descriptor.knowledge_schemas {
990 let original = schema.clone();
991 schema.canonicalize();
992 schema.validate().map_err(|error| {
993 invalid_snapshot_error(format!("invalid knowledge schema: {error}"))
994 })?;
995 if *schema != original {
996 return invalid_snapshot(
997 "plugin knowledge-schema declarations are not in canonical order",
998 );
999 }
1000 if let Some(owner) = registry.knowledge_kind_owners.get(&schema.id.kind) {
1001 if owner != &plugin {
1002 return invalid_snapshot(format!(
1003 "knowledge kind {:?} is owned by both {owner} and {plugin}",
1004 schema.id.kind
1005 ));
1006 }
1007 } else {
1008 registry
1009 .knowledge_kind_owners
1010 .insert(schema.id.kind.clone(), plugin.clone());
1011 }
1012 if registry
1013 .knowledge_schemas
1014 .insert(schema.id.clone(), (plugin.clone(), schema.clone()))
1015 .is_some()
1016 {
1017 return invalid_snapshot("knowledge schema ID is duplicated");
1018 }
1019 }
1020 if descriptor
1021 .systems
1022 .windows(2)
1023 .any(|pair| (pair[0].phase, &pair[0].name) >= (pair[1].phase, &pair[1].name))
1024 {
1025 return invalid_snapshot("plugin systems are not in canonical order");
1026 }
1027 let mut system_names = BTreeSet::new();
1028 for contract in &mut descriptor.systems {
1029 if !system_names.insert(contract.name.clone()) {
1030 return invalid_snapshot("plugin descriptor has duplicate system names");
1031 }
1032 let original = contract.clone();
1033 validate_system_contract(&plugin, contract).map_err(|error| {
1034 invalid_snapshot_error(format!("invalid plugin system descriptor: {error}"))
1035 })?;
1036 if *contract != original {
1037 return invalid_snapshot(
1038 "plugin system reads and writes are not in canonical order",
1039 );
1040 }
1041 if contract
1042 .writes
1043 .iter()
1044 .any(|state| is_domain_record_state(®istry.record_schemas, state))
1045 {
1046 return invalid_snapshot(
1047 "plugin systems cannot expose domain records as immediate component state",
1048 );
1049 }
1050 register_state_owners(&mut registry.state_owners, &plugin, &contract.writes)
1051 .map_err(|error| {
1052 invalid_snapshot_error(format!(
1053 "invalid plugin state ownership descriptor: {error}"
1054 ))
1055 })?;
1056 register_immediate_write_states(
1057 &mut registry.immediate_write_states,
1058 ®istry.boundary_writers,
1059 &plugin,
1060 &contract.writes,
1061 )
1062 .map_err(|error| {
1063 invalid_snapshot_error(format!(
1064 "invalid immediate state writer descriptor: {error}"
1065 ))
1066 })?;
1067 }
1068 if descriptor
1069 .boundary_systems
1070 .windows(2)
1071 .any(|pair| (pair[0].phase, &pair[0].name) >= (pair[1].phase, &pair[1].name))
1072 {
1073 return invalid_snapshot("boundary systems are not in canonical order");
1074 }
1075 for contract in &mut descriptor.boundary_systems {
1076 if !system_names.insert(contract.name.clone()) {
1077 return invalid_snapshot("plugin descriptor has duplicate system names");
1078 }
1079 let original = contract.clone();
1080 validate_boundary_system_contract(contract).map_err(|error| {
1081 invalid_snapshot_error(format!("invalid boundary system descriptor: {error}"))
1082 })?;
1083 validate_knowledge_write_grants(&plugin, contract, ®istry.knowledge_schemas)
1084 .map_err(|error| {
1085 invalid_snapshot_error(format!(
1086 "invalid boundary knowledge writer descriptor: {error}"
1087 ))
1088 })?;
1089 if *contract != original {
1090 return invalid_snapshot(
1091 "boundary system declarations are not in canonical order",
1092 );
1093 }
1094 let mut owned_state = contract.writes.clone();
1095 owned_state.extend(contract.reservation_offers.iter().cloned());
1096 owned_state.sort();
1097 owned_state.dedup();
1098 register_state_owners(&mut registry.state_owners, &plugin, &owned_state).map_err(
1099 |error| {
1100 invalid_snapshot_error(format!(
1101 "invalid boundary state ownership descriptor: {error}"
1102 ))
1103 },
1104 )?;
1105 register_boundary_writers(
1106 &mut registry.boundary_writers,
1107 ®istry.immediate_write_states,
1108 &plugin,
1109 &contract.name,
1110 contract.phase,
1111 &contract.writes,
1112 )
1113 .map_err(|error| {
1114 invalid_snapshot_error(format!("invalid boundary writer descriptor: {error}"))
1115 })?;
1116 register_reservation_offerers(
1117 &mut registry.reservation_offerers,
1118 &plugin,
1119 &contract.name,
1120 &contract.reservation_offers,
1121 )
1122 .map_err(|error| {
1123 invalid_snapshot_error(format!(
1124 "invalid reservation offerer descriptor: {error}"
1125 ))
1126 })?;
1127 register_random_streams(
1128 &mut registry.random_stream_owners,
1129 &plugin,
1130 &contract.name,
1131 &contract.random_streams,
1132 )
1133 .map_err(|error| {
1134 invalid_snapshot_error(format!(
1135 "invalid random stream ownership descriptor: {error}"
1136 ))
1137 })?;
1138 }
1139 if descriptor
1140 .commands
1141 .windows(2)
1142 .any(|pair| pair[0].name >= pair[1].name)
1143 {
1144 return invalid_snapshot("plugin commands are not in canonical order");
1145 }
1146 let mut command_names = BTreeSet::new();
1147 for action in &mut descriptor.commands {
1148 if !command_names.insert(action.name.clone()) {
1149 return invalid_snapshot("plugin descriptor has duplicate command names");
1150 }
1151 let original = action.clone();
1152 validate_action_descriptor(&plugin, action).map_err(|error| {
1153 invalid_snapshot_error(format!("invalid plugin command descriptor: {error}"))
1154 })?;
1155 if *action != original {
1156 return invalid_snapshot(
1157 "plugin command reads and writes are not in canonical order",
1158 );
1159 }
1160 if action
1161 .writes
1162 .iter()
1163 .any(|state| is_domain_record_state(®istry.record_schemas, state))
1164 {
1165 return invalid_snapshot(
1166 "plugin commands cannot expose domain records as immediate component state",
1167 );
1168 }
1169 register_state_owners(&mut registry.state_owners, &plugin, &action.writes)
1170 .map_err(|error| {
1171 invalid_snapshot_error(format!(
1172 "invalid plugin state ownership descriptor: {error}"
1173 ))
1174 })?;
1175 register_immediate_write_states(
1176 &mut registry.immediate_write_states,
1177 ®istry.boundary_writers,
1178 &plugin,
1179 &action.writes,
1180 )
1181 .map_err(|error| {
1182 invalid_snapshot_error(format!(
1183 "invalid immediate state writer descriptor: {error}"
1184 ))
1185 })?;
1186 }
1187 if descriptor
1188 .ingress
1189 .windows(2)
1190 .any(|pair| pair[0].name >= pair[1].name)
1191 {
1192 return invalid_snapshot("plugin ingress types are not in canonical order");
1193 }
1194 for ingress in &descriptor.ingress {
1195 validate_ingress_descriptor(ingress).map_err(|error| {
1196 invalid_snapshot_error(format!("invalid plugin ingress descriptor: {error}"))
1197 })?;
1198 if registry
1199 .ingress
1200 .insert((plugin.clone(), ingress.name.clone()), ingress.clone())
1201 .is_some()
1202 {
1203 return invalid_snapshot("plugin descriptor has duplicate ingress types");
1204 }
1205 }
1206 for (event_type, audience) in &descriptor.event_audiences {
1207 validate_event_audience_name(event_type).map_err(|error| {
1208 invalid_snapshot_error(format!("invalid plugin event audience: {error}"))
1209 })?;
1210 validate_event_audience(audience).map_err(|error| {
1211 invalid_snapshot_error(format!("invalid plugin event audience: {error}"))
1212 })?;
1213 }
1214 let schema_types: BTreeSet<_> = descriptor.schema_types.iter().collect();
1215 if schema_types.len() != descriptor.schema_types.len()
1216 || descriptor
1217 .schema_types
1218 .windows(2)
1219 .any(|pair| pair[0] >= pair[1])
1220 || descriptor
1221 .schema_types
1222 .iter()
1223 .any(|name| name.trim().is_empty() || name != name.trim())
1224 {
1225 return invalid_snapshot("plugin descriptor has invalid schema type names");
1226 }
1227 previous_plugin = Some(plugin.clone());
1228 registry.descriptors.insert(plugin, descriptor);
1229 }
1230 validate_schema_set(®istry.knowledge_schemas, ®istry.knowledge_kind_owners).map_err(
1231 |error| invalid_snapshot_error(format!("invalid knowledge schema set: {error}")),
1232 )?;
1233 Ok(registry)
1234 }
1235
1236 pub(super) fn ensure_active(&self) -> Result<(), CanwuError> {
1237 let inactive: Vec<_> = self
1238 .descriptors
1239 .keys()
1240 .filter(|name| !self.active_plugins.contains(*name))
1241 .cloned()
1242 .collect();
1243 if inactive.is_empty() {
1244 return Ok(());
1245 }
1246 Err(CanwuError::new(
1247 ErrorCode::PluginNotActive,
1248 format!(
1249 "required plugin handlers are not active: {}",
1250 inactive.join(", ")
1251 ),
1252 ))
1253 }
1254}
1255
1256pub(super) fn validate_state_keys(keys: &mut Vec<StateKey>) -> Result<(), CanwuError> {
1257 for key in keys.iter() {
1258 if key.namespace.trim().is_empty()
1259 || key.name.trim().is_empty()
1260 || key.namespace != key.namespace.trim()
1261 || key.name != key.name.trim()
1262 {
1263 return Err(CanwuError::new(
1264 ErrorCode::InvalidPluginRegistration,
1265 "state keys require non-empty canonical namespace and name values",
1266 ));
1267 }
1268 }
1269 let unique: BTreeSet<_> = keys.drain(..).collect();
1270 keys.extend(unique);
1271 Ok(())
1272}
1273
1274fn validate_plugin_identity(
1275 name: &str,
1276 version: &str,
1277 semantic_hash: &str,
1278) -> Result<(), CanwuError> {
1279 if name.trim().is_empty()
1280 || name != name.trim()
1281 || version.trim().is_empty()
1282 || version != version.trim()
1283 || !is_canonical_hash(semantic_hash)
1284 {
1285 return Err(CanwuError::new(
1286 ErrorCode::InvalidPluginRegistration,
1287 "plugins require canonical names, versions, and 64-character semantic hashes",
1288 ));
1289 }
1290 Ok(())
1291}
1292
1293fn validate_system_contract(
1294 _plugin: &str,
1295 contract: &mut SystemContract,
1296) -> Result<(), CanwuError> {
1297 if contract.name.trim().is_empty() || contract.name != contract.name.trim() {
1298 return Err(CanwuError::new(
1299 ErrorCode::InvalidPluginRegistration,
1300 "plugin system name must be non-empty and have no surrounding whitespace",
1301 ));
1302 }
1303 if matches!(
1304 contract.phase,
1305 BoundaryPhase::EventIngress
1306 | BoundaryPhase::BoundarySnapshot
1307 | BoundaryPhase::AtomicDomainCommit
1308 | BoundaryPhase::ConditionalTransitionCommit
1309 ) {
1310 return Err(CanwuError::new(
1311 ErrorCode::InvalidPluginRegistration,
1312 format!("boundary phase {:?} is owned by the kernel", contract.phase),
1313 ));
1314 }
1315 if contract.cadence != SystemCadence::EventDriven {
1316 return Err(CanwuError::new(
1317 ErrorCode::InvalidPluginRegistration,
1318 format!(
1319 "system {} declares {:?} cadence, but the current runtime systems are event-driven only",
1320 contract.name, contract.cadence
1321 ),
1322 ));
1323 }
1324 if contract.visibility != StateVisibility::SameBoundary {
1325 return Err(CanwuError::new(
1326 ErrorCode::InvalidPluginRegistration,
1327 format!(
1328 "event-driven system {} must declare same-boundary visibility until the phased boundary runtime is active",
1329 contract.name
1330 ),
1331 ));
1332 }
1333 validate_state_keys(&mut contract.reads)?;
1334 validate_state_keys(&mut contract.writes)?;
1335 if contract.reads.contains(&StateKey::core_ingress()) {
1336 return Err(CanwuError::new(
1337 ErrorCode::InvalidPluginRegistration,
1338 "canonical ingress can be read only by phased boundary systems",
1339 ));
1340 }
1341 Ok(())
1342}
1343
1344fn validate_action_descriptor(
1345 _plugin: &str,
1346 descriptor: &mut PluginActionDescriptor,
1347) -> Result<(), CanwuError> {
1348 if descriptor.name.trim().is_empty() || descriptor.name != descriptor.name.trim() {
1349 return Err(CanwuError::new(
1350 ErrorCode::InvalidPluginRegistration,
1351 "plugin command names must be non-empty and have no surrounding whitespace",
1352 ));
1353 }
1354 if let PayloadSchema::Object { properties, .. } = &descriptor.payload_schema
1355 && properties
1356 .keys()
1357 .any(|name| name.trim().is_empty() || name != name.trim())
1358 {
1359 return Err(CanwuError::new(
1360 ErrorCode::InvalidPluginRegistration,
1361 "plugin payload schema property names cannot be empty",
1362 ));
1363 }
1364 validate_state_keys(&mut descriptor.reads)?;
1365 validate_state_keys(&mut descriptor.writes)?;
1366 if descriptor.reads.contains(&StateKey::core_ingress()) {
1367 return Err(CanwuError::new(
1368 ErrorCode::InvalidPluginRegistration,
1369 "plugin commands cannot inspect the canonical ingress queue",
1370 ));
1371 }
1372 Ok(())
1373}
1374
1375fn validate_ingress_descriptor(descriptor: &PluginIngressDescriptor) -> Result<(), CanwuError> {
1376 if descriptor.name.trim().is_empty()
1377 || descriptor.name != descriptor.name.trim()
1378 || descriptor.description.trim().is_empty()
1379 || descriptor.description != descriptor.description.trim()
1380 || descriptor.class == IngressClass::Command
1381 {
1382 return Err(CanwuError::new(
1383 ErrorCode::InvalidPluginRegistration,
1384 "plugin ingress types require canonical names/descriptions and cannot claim the core command class",
1385 ));
1386 }
1387 if let PayloadSchema::Object { properties, .. } = &descriptor.payload_schema
1388 && properties
1389 .keys()
1390 .any(|name| name.trim().is_empty() || name != name.trim())
1391 {
1392 return Err(CanwuError::new(
1393 ErrorCode::InvalidPluginRegistration,
1394 "plugin ingress payload property names cannot be empty",
1395 ));
1396 }
1397 Ok(())
1398}
1399
1400fn validate_boundary_system_contract(
1401 contract: &mut BoundarySystemContract,
1402) -> Result<(), CanwuError> {
1403 if contract.name.trim().is_empty() || contract.name != contract.name.trim() {
1404 return Err(CanwuError::new(
1405 ErrorCode::InvalidPluginRegistration,
1406 "boundary system name must be non-empty and canonical",
1407 ));
1408 }
1409 validate_state_keys(&mut contract.reads)?;
1410 validate_state_keys(&mut contract.writes)?;
1411 validate_state_keys(&mut contract.reservation_offers)?;
1412 validate_state_keys(&mut contract.reservation_requests)?;
1413 validate_reservation_refs(&mut contract.reservation_reads)?;
1414 validate_random_stream_keys(&mut contract.random_streams)?;
1415 validate_canonical_names(&mut contract.emits, "boundary event type")?;
1416 for grant in &mut contract.knowledge_writes {
1417 grant.visibilities.sort();
1418 grant.visibilities.dedup();
1419 if grant.visibilities.is_empty() {
1420 return Err(CanwuError::new(
1421 ErrorCode::InvalidPluginRegistration,
1422 "knowledge write grants require at least one visibility",
1423 ));
1424 }
1425 }
1426 contract
1427 .knowledge_writes
1428 .sort_by(|left, right| left.schema.cmp(&right.schema));
1429 if contract
1430 .knowledge_writes
1431 .windows(2)
1432 .any(|pair| pair[0].schema >= pair[1].schema)
1433 {
1434 return Err(CanwuError::new(
1435 ErrorCode::InvalidPluginRegistration,
1436 "knowledge write grants must name unique schemas in canonical order",
1437 ));
1438 }
1439 if !contract.knowledge_writes.is_empty()
1440 && !matches!(
1441 contract.phase,
1442 BoundaryPhase::PerceptionAndAttentionRefresh
1443 | BoundaryPhase::PerspectiveAndReportMaterialization
1444 )
1445 {
1446 return Err(CanwuError::new(
1447 ErrorCode::InvalidPluginRegistration,
1448 "knowledge publication is available only in phases 4 and 13",
1449 ));
1450 }
1451 if contract.plugin_ingress_targets.iter().any(|target| {
1452 target.target_plugin.trim().is_empty()
1453 || target.target_plugin != target.target_plugin.trim()
1454 || target.packet_type.trim().is_empty()
1455 || target.packet_type != target.packet_type.trim()
1456 }) {
1457 return Err(CanwuError::new(
1458 ErrorCode::InvalidPluginRegistration,
1459 "cross-plugin ingress targets require canonical plugin and packet names",
1460 ));
1461 }
1462 contract.plugin_ingress_targets.sort();
1463 if contract
1464 .plugin_ingress_targets
1465 .windows(2)
1466 .any(|pair| pair[0] >= pair[1])
1467 {
1468 return Err(CanwuError::new(
1469 ErrorCode::InvalidPluginRegistration,
1470 "cross-plugin ingress targets must be unique",
1471 ));
1472 }
1473
1474 let may_propose_changes = matches!(
1475 contract.phase,
1476 BoundaryPhase::DomainDeltaProposal
1477 | BoundaryPhase::HistoricalCandidateEvaluation
1478 | BoundaryPhase::StrategicAggregation
1479 | BoundaryPhase::PerspectiveAndReportMaterialization
1480 );
1481 if (!contract.writes.is_empty()
1482 || !contract.emits.is_empty()
1483 || !contract.plugin_ingress_targets.is_empty())
1484 && !may_propose_changes
1485 {
1486 return Err(CanwuError::new(
1487 ErrorCode::InvalidPluginRegistration,
1488 format!(
1489 "boundary system {} declares changes in kernel-owned phase {:?}",
1490 contract.name, contract.phase
1491 ),
1492 ));
1493 }
1494 let declares_reservations =
1495 !contract.reservation_offers.is_empty() || !contract.reservation_requests.is_empty();
1496 if declares_reservations && contract.phase != BoundaryPhase::ReservationAndAllocation {
1497 return Err(CanwuError::new(
1498 ErrorCode::InvalidPluginRegistration,
1499 format!(
1500 "boundary system {} declares reservations outside reservation and allocation",
1501 contract.name
1502 ),
1503 ));
1504 }
1505 if !contract.reservation_reads.is_empty()
1506 && contract.phase <= BoundaryPhase::ReservationAndAllocation
1507 {
1508 return Err(CanwuError::new(
1509 ErrorCode::InvalidPluginRegistration,
1510 format!(
1511 "boundary system {} reads allocations before reservation commit",
1512 contract.name
1513 ),
1514 ));
1515 }
1516 Ok(())
1517}
1518
1519fn validate_knowledge_write_grants(
1520 plugin: &str,
1521 contract: &BoundarySystemContract,
1522 schemas: &super::knowledge::KnowledgeSchemas,
1523) -> Result<(), CanwuError> {
1524 for grant in &contract.knowledge_writes {
1525 let Some((owner, schema)) = schemas.get(&grant.schema) else {
1526 return Err(CanwuError::new(
1527 ErrorCode::InvalidPluginRegistration,
1528 format!(
1529 "boundary system {plugin}.{} names an unregistered knowledge schema",
1530 contract.name
1531 ),
1532 ));
1533 };
1534 if owner != plugin || !schema.writable {
1535 return Err(CanwuError::new(
1536 ErrorCode::InvalidPluginRegistration,
1537 format!(
1538 "boundary system {plugin}.{} cannot write a foreign or read-only knowledge schema",
1539 contract.name
1540 ),
1541 ));
1542 }
1543 }
1544 Ok(())
1545}
1546
1547fn validate_reservation_refs(values: &mut Vec<ReservationRef>) -> Result<(), CanwuError> {
1548 if values.iter().any(|reservation| {
1549 reservation.plugin.trim().is_empty()
1550 || reservation.plugin != reservation.plugin.trim()
1551 || reservation.system.trim().is_empty()
1552 || reservation.system != reservation.system.trim()
1553 || reservation.request.trim().is_empty()
1554 || reservation.request != reservation.request.trim()
1555 }) {
1556 return Err(CanwuError::new(
1557 ErrorCode::InvalidPluginRegistration,
1558 "reservation read declarations must be non-empty and canonical",
1559 ));
1560 }
1561 let unique: BTreeSet<_> = values.drain(..).collect();
1562 values.extend(unique);
1563 Ok(())
1564}
1565
1566fn validate_random_stream_keys(values: &mut Vec<RandomStreamKey>) -> Result<(), CanwuError> {
1567 if values.iter().any(|stream| {
1568 stream.namespace.trim().is_empty()
1569 || stream.namespace != stream.namespace.trim()
1570 || stream.name.trim().is_empty()
1571 || stream.name != stream.name.trim()
1572 || stream.version == 0
1573 }) {
1574 return Err(CanwuError::new(
1575 ErrorCode::InvalidPluginRegistration,
1576 "random stream declarations require canonical names and a nonzero version",
1577 ));
1578 }
1579 let unique: BTreeSet<_> = values.drain(..).collect();
1580 values.extend(unique);
1581 Ok(())
1582}
1583
1584fn validate_canonical_names(values: &mut Vec<String>, label: &str) -> Result<(), CanwuError> {
1585 if values
1586 .iter()
1587 .any(|value| value.trim().is_empty() || value != value.trim())
1588 {
1589 return Err(CanwuError::new(
1590 ErrorCode::InvalidPluginRegistration,
1591 format!("{label} declarations must be non-empty and canonical"),
1592 ));
1593 }
1594 let unique: BTreeSet<_> = values.drain(..).collect();
1595 values.extend(unique);
1596 Ok(())
1597}
1598
1599fn validate_event_audience_name(event_type: &str) -> Result<(), CanwuError> {
1600 if !canonical_text(event_type) {
1601 return Err(CanwuError::new(
1602 ErrorCode::InvalidPluginRegistration,
1603 "plugin event audience names must be non-empty and canonical",
1604 ));
1605 }
1606 Ok(())
1607}
1608
1609fn validate_event_audience(audience: &EventAudience) -> Result<(), CanwuError> {
1610 match audience {
1611 EventAudience::Actor(actor) if actor.get() == 0 => {
1612 return Err(CanwuError::new(
1613 ErrorCode::InvalidPluginRegistration,
1614 "plugin event audience actors must use positive actor IDs",
1615 ));
1616 }
1617 EventAudience::Actors(actors) => {
1618 if actors.is_empty() || actors.iter().any(|actor| actor.get() == 0) {
1619 return Err(CanwuError::new(
1620 ErrorCode::InvalidPluginRegistration,
1621 "plugin event audience actor lists must contain positive actor IDs",
1622 ));
1623 }
1624 if actors.windows(2).any(|pair| pair[0] >= pair[1]) {
1625 return Err(CanwuError::new(
1626 ErrorCode::InvalidPluginRegistration,
1627 "plugin event audience actor lists must be sorted and unique",
1628 ));
1629 }
1630 }
1631 _ => {}
1632 }
1633 Ok(())
1634}
1635
1636fn register_state_owners(
1637 owners: &mut BTreeMap<StateKey, String>,
1638 plugin: &str,
1639 writes: &[StateKey],
1640) -> Result<(), CanwuError> {
1641 for key in writes {
1642 if key.namespace == CORE_STATE_NAMESPACE {
1643 return Err(CanwuError::new(
1644 ErrorCode::InvalidPluginRegistration,
1645 format!(
1646 "plugin {plugin} cannot claim reserved state {}.{}",
1647 key.namespace, key.name
1648 ),
1649 ));
1650 }
1651 if let Some(existing) = owners.get(key)
1652 && existing != plugin
1653 {
1654 return Err(CanwuError::new(
1655 ErrorCode::DuplicateStateOwner,
1656 format!(
1657 "state {}.{} is owned by both {existing} and {plugin}",
1658 key.namespace, key.name
1659 ),
1660 ));
1661 }
1662 }
1663 for key in writes {
1664 owners.insert(key.clone(), plugin.to_owned());
1665 }
1666 Ok(())
1667}
1668
1669fn register_boundary_writers(
1670 writers: &mut BTreeMap<(BoundaryWriteStage, StateKey), (String, String)>,
1671 immediate_writes: &BTreeMap<StateKey, String>,
1672 plugin: &str,
1673 system: &str,
1674 phase: BoundaryPhase,
1675 declared_states: &[StateKey],
1676) -> Result<(), CanwuError> {
1677 let Some(stage) = boundary_write_stage(phase) else {
1678 if declared_states.is_empty() {
1679 return Ok(());
1680 }
1681 return Err(CanwuError::new(
1682 ErrorCode::InvalidPluginRegistration,
1683 format!("boundary phase {phase:?} cannot own state writes"),
1684 ));
1685 };
1686 for state in declared_states {
1687 if let Some(immediate_plugin) = immediate_writes.get(state) {
1688 return Err(CanwuError::new(
1689 ErrorCode::InvalidPluginRegistration,
1690 format!(
1691 "boundary state {}.{} conflicts with immediate writes from plugin {immediate_plugin}",
1692 state.namespace, state.name
1693 ),
1694 ));
1695 }
1696 if let Some((existing_plugin, existing_system)) = writers.get(&(stage, state.clone()))
1697 && (existing_plugin != plugin || existing_system != system)
1698 {
1699 return Err(CanwuError::new(
1700 ErrorCode::DuplicateBoundaryWriter,
1701 format!(
1702 "boundary state {}.{} is written by both {existing_plugin}.{existing_system} and {plugin}.{system}",
1703 state.namespace, state.name
1704 ),
1705 ));
1706 }
1707 }
1708 for state in declared_states {
1709 writers.insert(
1710 (stage, state.clone()),
1711 (plugin.to_owned(), system.to_owned()),
1712 );
1713 }
1714 Ok(())
1715}
1716
1717fn register_immediate_write_states(
1718 immediate_writes: &mut BTreeMap<StateKey, String>,
1719 boundary_writers: &BTreeMap<(BoundaryWriteStage, StateKey), (String, String)>,
1720 plugin: &str,
1721 writes: &[StateKey],
1722) -> Result<(), CanwuError> {
1723 for state in writes {
1724 if boundary_writers
1725 .keys()
1726 .any(|(_, boundary_state)| boundary_state == state)
1727 {
1728 return Err(CanwuError::new(
1729 ErrorCode::InvalidPluginRegistration,
1730 format!(
1731 "immediate state {}.{} conflicts with a phased boundary writer",
1732 state.namespace, state.name
1733 ),
1734 ));
1735 }
1736 if immediate_writes
1737 .get(state)
1738 .is_some_and(|existing| existing != plugin)
1739 {
1740 return Err(CanwuError::new(
1741 ErrorCode::DuplicateStateOwner,
1742 format!(
1743 "immediate state {}.{} is written by multiple plugins",
1744 state.namespace, state.name
1745 ),
1746 ));
1747 }
1748 }
1749 for state in writes {
1750 immediate_writes.insert(state.clone(), plugin.to_owned());
1751 }
1752 Ok(())
1753}
1754
1755fn register_reservation_offerers(
1756 offerers: &mut BTreeMap<StateKey, (String, String)>,
1757 plugin: &str,
1758 system: &str,
1759 offered_state: &[StateKey],
1760) -> Result<(), CanwuError> {
1761 for state in offered_state {
1762 if let Some((existing_plugin, existing_system)) = offerers.get(state)
1763 && (existing_plugin != plugin || existing_system != system)
1764 {
1765 return Err(CanwuError::new(
1766 ErrorCode::DuplicateReservationOfferer,
1767 format!(
1768 "reservation state {}.{} is offered by both {existing_plugin}.{existing_system} and {plugin}.{system}",
1769 state.namespace, state.name
1770 ),
1771 ));
1772 }
1773 }
1774 for state in offered_state {
1775 offerers.insert(state.clone(), (plugin.to_owned(), system.to_owned()));
1776 }
1777 Ok(())
1778}
1779
1780fn register_random_streams(
1781 owners: &mut BTreeMap<RandomStreamKey, (String, String)>,
1782 plugin: &str,
1783 system: &str,
1784 streams: &[RandomStreamKey],
1785) -> Result<(), CanwuError> {
1786 for stream in streams {
1787 if stream.namespace != plugin || stream.namespace == CORE_STATE_NAMESPACE {
1788 return Err(CanwuError::new(
1789 ErrorCode::InvalidPluginRegistration,
1790 format!(
1791 "random stream {}.{}@{} must use its owning plugin namespace {plugin}",
1792 stream.namespace, stream.name, stream.version
1793 ),
1794 ));
1795 }
1796 if let Some((existing_plugin, existing_system)) = owners.get(stream)
1797 && (existing_plugin != plugin || existing_system != system)
1798 {
1799 return Err(CanwuError::new(
1800 ErrorCode::InvalidPluginRegistration,
1801 format!(
1802 "random stream {}.{}@{} is owned by both {existing_plugin}.{existing_system} and {plugin}.{system}",
1803 stream.namespace, stream.name, stream.version
1804 ),
1805 ));
1806 }
1807 }
1808 for stream in streams {
1809 owners.insert(stream.clone(), (plugin.to_owned(), system.to_owned()));
1810 }
1811 Ok(())
1812}
1813
1814#[cfg(test)]
1815mod tests {
1816 use super::super::{KnowledgeSubjectSchema, KnowledgeSubjectTargetKind};
1817 use super::*;
1818 use canwu_core::{CoreEntityKind, KnowledgeRecordKind, KnowledgeSchemaId};
1819
1820 struct KnowledgeSchemaPlugin {
1821 name: &'static str,
1822 schemas: Vec<PluginKnowledgeSchema>,
1823 }
1824
1825 impl SimulationPlugin for KnowledgeSchemaPlugin {
1826 fn name(&self) -> &str {
1827 self.name
1828 }
1829
1830 fn version(&self) -> &'static str {
1831 "1"
1832 }
1833
1834 fn semantic_hash(&self) -> &'static str {
1835 "0000000000000000000000000000000000000000000000000000000000000001"
1836 }
1837
1838 fn register(&self, registrar: &mut PluginRegistrar<'_>) -> Result<(), CanwuError> {
1839 for schema in &self.schemas {
1840 registrar.register_knowledge_schema(schema.clone())?;
1841 }
1842 Ok(())
1843 }
1844 }
1845
1846 fn knowledge_kind() -> KnowledgeRecordKind {
1847 KnowledgeRecordKind::new("fixture.knowledge", "assessment")
1848 }
1849
1850 fn knowledge_schema(version: u32, writable: bool) -> PluginKnowledgeSchema {
1851 PluginKnowledgeSchema {
1852 id: KnowledgeSchemaId::new(knowledge_kind(), version),
1853 schema_hash: format!("{version:064x}"),
1854 writable,
1855 payload_schema: PayloadSchema::Any,
1856 subjects: vec![],
1857 }
1858 }
1859
1860 #[test]
1861 fn duplicate_schema_and_writable_conflicts_roll_back_registration() {
1862 let duplicate = KnowledgeSchemaPlugin {
1863 name: "duplicate-knowledge",
1864 schemas: vec![knowledge_schema(1, true), knowledge_schema(1, true)],
1865 };
1866 let mut registry = PluginRegistry::default();
1867 let mut types = SchemaRegistry::default();
1868 assert!(registry.register(&duplicate, &mut types).is_err());
1869 assert!(registry.descriptors.is_empty());
1870 assert!(registry.knowledge_schemas.is_empty());
1871 assert!(registry.knowledge_kind_owners.is_empty());
1872
1873 let two_writable = KnowledgeSchemaPlugin {
1874 name: "two-writable-knowledge",
1875 schemas: vec![knowledge_schema(1, true), knowledge_schema(2, true)],
1876 };
1877 assert!(registry.register(&two_writable, &mut types).is_err());
1878 assert!(registry.descriptors.is_empty());
1879 assert!(registry.knowledge_schemas.is_empty());
1880
1881 let first_owner = KnowledgeSchemaPlugin {
1882 name: "first-knowledge-owner",
1883 schemas: vec![knowledge_schema(1, true)],
1884 };
1885 registry
1886 .register(&first_owner, &mut types)
1887 .expect("the first kind owner should register");
1888 let before = registry.clone();
1889 let second_owner = KnowledgeSchemaPlugin {
1890 name: "second-knowledge-owner",
1891 schemas: vec![knowledge_schema(2, true)],
1892 };
1893 assert!(registry.register(&second_owner, &mut types).is_err());
1894 assert_eq!(registry.descriptors, before.descriptors);
1895 assert_eq!(registry.knowledge_schemas, before.knowledge_schemas);
1896 assert_eq!(registry.knowledge_kind_owners, before.knowledge_kind_owners);
1897 }
1898
1899 #[test]
1900 fn schema_hash_mismatch_blocks_exact_rehydration() {
1901 let plugin = KnowledgeSchemaPlugin {
1902 name: "rehydrated-knowledge",
1903 schemas: vec![knowledge_schema(1, true)],
1904 };
1905 let mut registry = PluginRegistry::default();
1906 let mut types = SchemaRegistry::default();
1907 registry
1908 .register(&plugin, &mut types)
1909 .expect("fixture plugin should register");
1910 let mut descriptors = registry.descriptors().cloned().collect::<Vec<_>>();
1911 descriptors[0].knowledge_schemas[0].schema_hash =
1912 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_owned();
1913 let mut rehydrated = PluginRegistry::from_descriptors(descriptors)
1914 .expect("the altered descriptor remains structurally valid");
1915 let error = rehydrated
1916 .register(&plugin, &mut SchemaRegistry::default())
1917 .expect_err("exact rehydration must compare the persisted schema hash");
1918 assert_eq!(error.code, ErrorCode::PluginManifestMismatch);
1919 }
1920
1921 #[test]
1922 fn schema_limit_accepts_boundary_and_rejects_plus_one_atomically() {
1923 let boundary = KnowledgeSchemaPlugin {
1924 name: "knowledge-limit-boundary",
1925 schemas: (1..=KnowledgeLimitsV1::CURRENT.schemas_per_plugin)
1926 .map(|version| {
1927 knowledge_schema(
1928 u32::try_from(version).expect("schema limit fits u32"),
1929 version == 1,
1930 )
1931 })
1932 .collect(),
1933 };
1934 let mut registry = PluginRegistry::default();
1935 registry
1936 .register(&boundary, &mut SchemaRegistry::default())
1937 .expect("the exact schema limit should be admitted");
1938 assert_eq!(
1939 registry.knowledge_schemas.len(),
1940 KnowledgeLimitsV1::CURRENT.schemas_per_plugin
1941 );
1942
1943 let overflow = KnowledgeSchemaPlugin {
1944 name: "knowledge-limit-overflow",
1945 schemas: (1..=KnowledgeLimitsV1::CURRENT.schemas_per_plugin + 1)
1946 .map(|version| {
1947 knowledge_schema(
1948 u32::try_from(version).expect("schema limit fits u32"),
1949 version == 1,
1950 )
1951 })
1952 .collect(),
1953 };
1954 let mut rejected = PluginRegistry::default();
1955 let error = rejected
1956 .register(&overflow, &mut SchemaRegistry::default())
1957 .expect_err("schema limit plus one must reject the whole plugin");
1958 assert_eq!(error.code, ErrorCode::InvalidPluginRegistration);
1959 assert!(rejected.descriptors.is_empty());
1960 assert!(rejected.knowledge_schemas.is_empty());
1961 }
1962
1963 #[test]
1964 fn knowledge_schema_registration_canonicalizes_roles_and_targets() {
1965 let mut schema = knowledge_schema(1, true);
1966 schema.subjects = vec![
1967 KnowledgeSubjectSchema {
1968 role: "zeta".to_owned(),
1969 targets: vec![
1970 KnowledgeSubjectTargetKind::AnyEntity,
1971 KnowledgeSubjectTargetKind::Core(CoreEntityKind::Person),
1972 KnowledgeSubjectTargetKind::AnyEntity,
1973 ],
1974 required: false,
1975 multiple: true,
1976 },
1977 KnowledgeSubjectSchema {
1978 role: "alpha".to_owned(),
1979 targets: vec![KnowledgeSubjectTargetKind::Event],
1980 required: true,
1981 multiple: false,
1982 },
1983 ];
1984 let plugin = KnowledgeSchemaPlugin {
1985 name: "canonical-knowledge",
1986 schemas: vec![schema],
1987 };
1988 let mut registry = PluginRegistry::default();
1989 registry
1990 .register(&plugin, &mut SchemaRegistry::default())
1991 .expect("registrar should canonicalize declarations transactionally");
1992 let stored = ®istry
1993 .descriptors
1994 .get(plugin.name)
1995 .expect("descriptor exists")
1996 .knowledge_schemas[0];
1997 assert_eq!(stored.subjects[0].role, "alpha");
1998 assert_eq!(stored.subjects[1].role, "zeta");
1999 assert_eq!(stored.subjects[1].targets.len(), 2);
2000 assert!(stored.validate().is_ok());
2001 }
2002
2003 #[test]
2004 fn invalid_schema_version_and_hash_roll_back_registration() {
2005 let mut version_zero = knowledge_schema(0, true);
2006 version_zero.schema_hash =
2007 "0000000000000000000000000000000000000000000000000000000000000000".to_owned();
2008 let invalid_version = KnowledgeSchemaPlugin {
2009 name: "invalid-knowledge-version",
2010 schemas: vec![version_zero],
2011 };
2012 let mut registry = PluginRegistry::default();
2013 let mut types = SchemaRegistry::default();
2014 assert!(registry.register(&invalid_version, &mut types).is_err());
2015 assert!(registry.descriptors.is_empty());
2016 assert!(registry.knowledge_schemas.is_empty());
2017
2018 let mut bad_hash = knowledge_schema(1, true);
2019 bad_hash.schema_hash = "not-a-canonical-hash".to_owned();
2020 let invalid_hash = KnowledgeSchemaPlugin {
2021 name: "invalid-knowledge-hash",
2022 schemas: vec![bad_hash],
2023 };
2024 assert!(registry.register(&invalid_hash, &mut types).is_err());
2025 assert!(registry.descriptors.is_empty());
2026 assert!(registry.knowledge_schemas.is_empty());
2027 }
2028
2029 #[test]
2030 fn knowledge_write_grants_reject_invalid_phase_and_foreign_owner() {
2031 #[allow(clippy::unnecessary_wraps)]
2032 fn no_op_boundary(
2033 _view: &crate::SimulationView<'_>,
2034 _context: &crate::BoundaryContext,
2035 ) -> Result<crate::BoundaryProposal, CanwuError> {
2036 Ok(crate::BoundaryProposal::default())
2037 }
2038
2039 let owner = KnowledgeSchemaPlugin {
2040 name: "knowledge-grant-owner",
2041 schemas: vec![knowledge_schema(1, true)],
2042 };
2043 let foreign = KnowledgeSchemaPlugin {
2044 name: "knowledge-grant-foreign",
2045 schemas: vec![PluginKnowledgeSchema {
2046 id: KnowledgeSchemaId::new(
2047 KnowledgeRecordKind::new("fixture.foreign", "assessment"),
2048 1,
2049 ),
2050 schema_hash: "f000000000000000000000000000000000000000000000000000000000000000"
2051 .to_owned(),
2052 writable: true,
2053 payload_schema: PayloadSchema::Any,
2054 subjects: Vec::new(),
2055 }],
2056 };
2057 let mut registry = PluginRegistry::default();
2058 let mut types = SchemaRegistry::default();
2059 registry
2060 .register(&owner, &mut types)
2061 .expect("knowledge owner should register");
2062 registry
2063 .register(&foreign, &mut types)
2064 .expect("foreign fixture should register");
2065 let before = registry.clone();
2066
2067 let mut phase7 = BoundarySystemContract::new(
2068 "invalid-phase7-publication",
2069 crate::BoundaryPhase::DomainDeltaProposal,
2070 SystemCadence::Daily,
2071 );
2072 phase7.knowledge_writes = vec![crate::KnowledgeWriteGrant {
2073 schema: knowledge_schema(1, true).id,
2074 visibilities: vec![StateVisibility::SameBoundary],
2075 }];
2076 let mut owner_registry = registry.clone();
2077 let mut owner_types = types.clone();
2078 let mut registrar = PluginRegistrar {
2079 plugin: owner.name.to_owned(),
2080 registry: &mut owner_registry,
2081 schema: &mut owner_types,
2082 };
2083 let error = registrar
2084 .register_boundary_system(phase7, no_op_boundary)
2085 .expect_err("phase 7 must reject knowledge publication grants");
2086 assert_eq!(error.code, ErrorCode::InvalidPluginRegistration);
2087 assert_eq!(owner_registry.descriptors, before.descriptors);
2088
2089 let mut foreign_grant = BoundarySystemContract::new(
2090 "foreign-knowledge-grant",
2091 crate::BoundaryPhase::PerspectiveAndReportMaterialization,
2092 SystemCadence::Daily,
2093 );
2094 foreign_grant.knowledge_writes = vec![crate::KnowledgeWriteGrant {
2095 schema: knowledge_schema(1, true).id,
2096 visibilities: vec![StateVisibility::SameBoundary],
2097 }];
2098 let mut foreign_registry = registry;
2099 let mut registrar = PluginRegistrar {
2100 plugin: foreign.name.to_owned(),
2101 registry: &mut foreign_registry,
2102 schema: &mut types,
2103 };
2104 let error = registrar
2105 .register_boundary_system(foreign_grant, no_op_boundary)
2106 .expect_err("a plugin cannot claim another plugin's writable schema");
2107 assert_eq!(error.code, ErrorCode::InvalidPluginRegistration);
2108 assert_eq!(foreign_registry.descriptors, before.descriptors);
2109 }
2110}