Skip to main content

canwu_sim/runtime/
plugins.rs

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    /// Declarative visibility policies for emitted plugin event types.
132    /// Unlisted event types are private to player-facing projections.
133    #[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    /// Queues ingress for the issuing plugin. A zero delay is still admitted
185    /// only at the next boundary cut.
186    EnqueuePluginIngress {
187        after: SimDuration,
188        packet_type: String,
189        priority: i32,
190        payload: Value,
191        affected: Vec<EntityRef>,
192    },
193}
194
195/// Compatibility-only synchronous event reactor.
196///
197/// The handler runs inside the event's current transaction. Emitting another
198/// event from its directives re-enters the same reactor graph and is bounded
199/// by [`super::MAX_SYNCHRONOUS_REACTION_DEPTH`]. New mechanics should use a phased
200/// [`BoundarySystemHandler`] instead.
201pub type SimulationSystemHandler =
202    fn(&SimulationView<'_>, &SimEvent) -> Result<Vec<SystemDirective>, CanwuError>;
203
204pub type PluginCommandHandler =
205    fn(&SimulationView<'_>, &CommandContext, &Value) -> Result<Vec<SystemDirective>, CanwuError>;
206
207/// A stateless executable package whose persisted identity must change whenever
208/// its authoritative behavior changes.
209pub trait SimulationPlugin {
210    fn name(&self) -> &str;
211    /// Returns the package or rules release recorded in snapshots.
212    fn version(&self) -> &str;
213    /// Returns a lowercase 64-character author-controlled semantic hash.
214    ///
215    /// This must change when handler behavior changes even if the serialized
216    /// registration descriptor remains structurally identical.
217    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    /// Declares the player-facing audience for one emitted plugin event type.
506    ///
507    /// This policy is persisted in the plugin descriptor and is independent
508    /// from the plugin system subscription graph. Event types without a
509    /// declaration remain private to player-facing projections.
510    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        // This registration path is retained for the movement slice and
547        // legacy plugins. New mechanics should register a phased boundary
548        // system so their writes are staged and committed atomically.
549        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(&registry.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                    &registry.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, &registry.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                    &registry.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(&registry.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                    &registry.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(&registry.knowledge_schemas, &registry.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 = &registry
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}