1use super::{CanwuError, ErrorCode, PayloadSchema, StateKey, StateVisibility};
2use canwu_core::{
3 CoreEntityKind, DomainEntityType, DomainKindClass, DomainRecordKind, DomainRecordRef,
4 DomainRecordType, DomainValueType, EntityRef, KnowledgeHolderPolicy, TypedDomainRecordRef,
5};
6use canwu_time::SimTime;
7use serde::de::DeserializeOwned;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::{BTreeMap, BTreeSet};
11
12#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum DomainRecordClass {
15 Entity,
16 Record,
17}
18
19#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
20#[serde(tag = "type", content = "kind", rename_all = "snake_case")]
21pub enum DomainReferenceTargetKind {
22 Core(CoreEntityKind),
23 Domain(DomainRecordKind),
24 AnyEntity,
25}
26
27impl DomainReferenceTargetKind {
28 #[must_use]
29 pub fn for_domain<T: DomainRecordType>() -> Self {
30 Self::Domain(DomainRecordKind::for_type::<T>())
31 }
32}
33
34#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
35pub struct DomainReferenceSchema {
36 pub role: String,
37 pub targets: Vec<DomainReferenceTargetKind>,
38 pub required: bool,
39 pub multiple: bool,
40 pub allow_retired: bool,
41}
42
43#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
44#[serde(rename_all = "snake_case")]
45pub enum DomainRecordMutationPolicy {
46 #[default]
47 Versioned,
48 CreateOnly,
49}
50
51#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
52pub struct DomainRecordSchema {
53 pub kind: DomainRecordKind,
54 pub class: DomainRecordClass,
55 #[serde(default)]
56 pub holder_policy: KnowledgeHolderPolicy,
57 #[serde(default)]
58 pub mutation_policy: DomainRecordMutationPolicy,
59 pub payload_schema: PayloadSchema,
60 pub references: Vec<DomainReferenceSchema>,
61}
62
63impl DomainRecordSchema {
64 #[must_use]
65 pub fn new(kind: DomainRecordKind, class: DomainRecordClass) -> Self {
66 Self {
67 kind,
68 class,
69 holder_policy: KnowledgeHolderPolicy::Disallowed,
70 mutation_policy: DomainRecordMutationPolicy::Versioned,
71 payload_schema: PayloadSchema::Any,
72 references: Vec::new(),
73 }
74 }
75
76 #[must_use]
77 pub fn for_type<T: DomainRecordType>() -> Self {
78 let class = if T::Class::IS_ENTITY {
79 DomainRecordClass::Entity
80 } else {
81 DomainRecordClass::Record
82 };
83 Self::new(DomainRecordKind::for_type::<T>(), class)
84 }
85
86 #[must_use]
87 pub fn for_entity<T: DomainEntityType>() -> Self {
88 Self::for_type::<T>()
89 }
90
91 #[must_use]
92 pub fn for_record<T: DomainValueType>() -> Self {
93 Self::for_type::<T>()
94 }
95
96 #[must_use]
97 pub fn state_key(&self) -> StateKey {
98 record_state_key(&self.kind)
99 }
100
101 pub(crate) fn canonicalize(&mut self) {
102 for reference in &mut self.references {
103 reference.targets.sort();
104 reference.targets.dedup();
105 }
106 self.references
107 .sort_by(|left, right| left.role.cmp(&right.role));
108 }
109
110 pub(crate) fn validate(&self) -> Result<(), CanwuError> {
111 validate_kind(&self.kind)?;
112 if self.holder_policy == KnowledgeHolderPolicy::Allowed
113 && self.class != DomainRecordClass::Entity
114 {
115 return invalid_record("only domain entity schemas may allow knowledge holders");
116 }
117 if let PayloadSchema::Object { properties, .. } = &self.payload_schema
118 && properties.keys().any(|name| !canonical_text(name))
119 {
120 return invalid_record(
121 "record payload-schema property names must be non-empty and canonical",
122 );
123 }
124 if self
125 .references
126 .windows(2)
127 .any(|pair| pair[0].role >= pair[1].role)
128 {
129 return invalid_record("record-schema reference roles must be unique and sorted");
130 }
131 for reference in &self.references {
132 if !canonical_text(&reference.role)
133 || reference.targets.is_empty()
134 || reference.targets.windows(2).any(|pair| pair[0] >= pair[1])
135 {
136 return invalid_record(
137 "record-schema references require canonical roles and unique sorted targets",
138 );
139 }
140 for target in &reference.targets {
141 if let DomainReferenceTargetKind::Domain(kind) = target {
142 validate_kind(kind)?;
143 }
144 }
145 }
146 Ok(())
147 }
148}
149
150#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
151#[serde(tag = "type", content = "reference", rename_all = "snake_case")]
152pub enum DomainReferenceTarget {
153 Core(EntityRef),
154 Domain(DomainRecordRef),
155}
156
157impl DomainReferenceTarget {
158 #[must_use]
159 pub fn from_typed<T: DomainRecordType>(reference: TypedDomainRecordRef<T>) -> Self {
160 Self::Domain(reference.into_untyped())
161 }
162}
163
164#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
165pub struct DomainReference {
166 pub role: String,
167 pub target: DomainReferenceTarget,
168}
169
170impl DomainReference {
171 #[must_use]
172 pub fn from_typed<T: DomainRecordType>(
173 role: impl Into<String>,
174 reference: TypedDomainRecordRef<T>,
175 ) -> Self {
176 Self {
177 role: role.into(),
178 target: DomainReferenceTarget::from_typed(reference),
179 }
180 }
181}
182
183#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
184pub struct DomainRecordDraft {
185 pub reference: DomainRecordRef,
186 pub payload: Value,
187 pub references: Vec<DomainReference>,
188}
189
190impl DomainRecordDraft {
191 #[must_use]
192 pub fn new(reference: DomainRecordRef, payload: Value) -> Self {
193 Self {
194 reference,
195 payload,
196 references: Vec::new(),
197 }
198 }
199
200 pub fn from_typed<T: DomainRecordType>(
201 reference: TypedDomainRecordRef<T>,
202 payload: &T::Payload,
203 ) -> Result<Self, CanwuError>
204 where
205 T::Payload: Serialize,
206 {
207 let payload = serde_json::to_value(payload).map_err(|error| {
208 CanwuError::new(
209 ErrorCode::InvalidDomainRecord,
210 format!(
211 "typed domain payload for {} could not be encoded: {error}",
212 DomainRecordKind::for_type::<T>()
213 ),
214 )
215 })?;
216 Ok(Self::new(reference.into_untyped(), payload))
217 }
218
219 pub(crate) fn canonicalize(&mut self) {
220 self.references.sort();
221 self.references.dedup();
222 }
223}
224
225#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
226#[serde(tag = "state", rename_all = "snake_case")]
227pub enum DomainRecordLifecycle {
228 Active,
229 Retired {
230 at: SimTime,
231 successor: Option<DomainRecordRef>,
232 },
233 Deleted {
234 at: SimTime,
235 },
236}
237
238#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
239pub struct DomainRecord {
240 pub reference: DomainRecordRef,
241 pub owner: String,
242 pub class: DomainRecordClass,
243 pub version: u64,
244 pub lifecycle: DomainRecordLifecycle,
245 pub payload: Value,
246 pub references: Vec<DomainReference>,
247}
248
249impl DomainRecord {
250 #[must_use]
251 pub const fn is_deleted(&self) -> bool {
252 matches!(self.lifecycle, DomainRecordLifecycle::Deleted { .. })
253 }
254
255 #[must_use]
256 pub const fn is_active(&self) -> bool {
257 matches!(self.lifecycle, DomainRecordLifecycle::Active)
258 }
259
260 #[must_use]
261 pub fn typed_reference<T: DomainRecordType>(&self) -> Option<TypedDomainRecordRef<T>> {
262 TypedDomainRecordRef::from_untyped(self.reference.clone()).ok()
263 }
264
265 pub fn decode_payload<T: DomainRecordType>(&self) -> Result<T::Payload, CanwuError>
266 where
267 T::Payload: DeserializeOwned,
268 {
269 if !self.reference.kind.matches_type::<T>() {
270 return Err(CanwuError::new(
271 ErrorCode::InvalidDomainRecord,
272 format!(
273 "domain record {} cannot be decoded as kind {}",
274 self.reference,
275 DomainRecordKind::for_type::<T>()
276 ),
277 ));
278 }
279 T::Payload::deserialize(&self.payload).map_err(|error| {
280 CanwuError::new(
281 ErrorCode::InvalidDomainRecord,
282 format!(
283 "domain record {} has an incompatible typed payload: {error}",
284 self.reference
285 ),
286 )
287 })
288 }
289}
290
291#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
292#[serde(tag = "operation", rename_all = "snake_case")]
293pub enum DomainRecordMutation {
294 Create {
295 record: DomainRecordDraft,
296 },
297 Update {
298 record: DomainRecordDraft,
299 expected_version: u64,
300 },
301 Retire {
302 record: DomainRecordRef,
303 expected_version: u64,
304 successor: Option<DomainRecordRef>,
305 },
306 Delete {
307 record: DomainRecordRef,
308 expected_version: u64,
309 },
310}
311
312impl DomainRecordMutation {
313 #[must_use]
314 pub const fn target(&self) -> &DomainRecordRef {
315 match self {
316 Self::Create { record } | Self::Update { record, .. } => &record.reference,
317 Self::Retire { record, .. } | Self::Delete { record, .. } => record,
318 }
319 }
320}
321
322#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
323#[serde(rename_all = "snake_case")]
324pub enum DomainRecordOperation {
325 Created,
326 Updated,
327 Retired,
328 Deleted,
329}
330
331impl DomainRecordOperation {
332 pub(crate) const fn event_type(self) -> &'static str {
333 match self {
334 Self::Created => "domain_record_created",
335 Self::Updated => "domain_record_updated",
336 Self::Retired => "domain_record_retired",
337 Self::Deleted => "domain_record_deleted",
338 }
339 }
340}
341
342#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
343pub struct DomainRecordChange {
344 pub plugin: String,
345 pub system: String,
346 pub operation: DomainRecordOperation,
347 pub previous: Option<DomainRecord>,
348 pub current: DomainRecord,
349 pub visibility: StateVisibility,
350 pub summary: String,
351}
352
353pub(crate) type DomainRecordSchemas = BTreeMap<DomainRecordKind, (String, DomainRecordSchema)>;
354
355pub(crate) struct DomainMutationRequest<'a> {
356 pub plugin: &'a str,
357 pub system: &'a str,
358 pub visibility: StateVisibility,
359 pub mutation: &'a DomainRecordMutation,
360 pub summary: &'a str,
361}
362
363pub(crate) fn record_state_key(kind: &DomainRecordKind) -> StateKey {
364 StateKey::new(&kind.namespace, &kind.name)
365}
366
367pub(crate) fn validate_initial_records(
368 records: &[DomainRecord],
369 now: SimTime,
370 core_exists: &dyn Fn(&EntityRef) -> bool,
371) -> Result<(), CanwuError> {
372 let mut store = BTreeMap::new();
373 for record in records {
374 validate_record_shape(record, now)?;
375 if store
376 .insert(record.reference.clone(), record.clone())
377 .is_some()
378 {
379 return Err(CanwuError::new(
380 ErrorCode::DuplicateDomainRecord,
381 format!("domain record {} is duplicated", record.reference),
382 ));
383 }
384 }
385 for record in store.values().filter(|record| !record.is_deleted()) {
386 validate_reference_targets_basic(record, &store, core_exists)?;
387 validate_successor(record, &store)?;
388 }
389 validate_successor_graph(&store)?;
390 Ok(())
391}
392
393pub(crate) fn validate_record_store(
394 records: &BTreeMap<DomainRecordRef, DomainRecord>,
395 schemas: &DomainRecordSchemas,
396 now: SimTime,
397 core_exists: &dyn Fn(&EntityRef) -> bool,
398) -> Result<(), CanwuError> {
399 for (reference, record) in records {
400 if reference != &record.reference {
401 return invalid_record("domain record map key disagrees with its stable reference");
402 }
403 validate_record_shape(record, now)?;
404 let Some((owner, schema)) = schemas.get(&record.reference.kind) else {
405 return Err(CanwuError::new(
406 ErrorCode::PluginNotActive,
407 format!(
408 "domain record kind {} has no active schema owner",
409 record.reference.kind
410 ),
411 ));
412 };
413 if &record.owner != owner || record.class != schema.class {
414 return invalid_record(format!(
415 "domain record {} disagrees with its schema owner or class",
416 record.reference
417 ));
418 }
419 schema.payload_schema.validate(&record.payload)?;
420 validate_record_references(record, schema, records, core_exists)?;
421 validate_successor(record, records)?;
422 }
423 validate_successor_graph(records)?;
424 Ok(())
425}
426
427pub(crate) fn validate_records_for_owner(
428 records: &BTreeMap<DomainRecordRef, DomainRecord>,
429 schemas: &DomainRecordSchemas,
430 owner: &str,
431 now: SimTime,
432 core_exists: &dyn Fn(&EntityRef) -> bool,
433) -> Result<(), CanwuError> {
434 for record in records.values().filter(|record| record.owner == owner) {
435 validate_record_shape(record, now)?;
436 let Some((schema_owner, schema)) = schemas.get(&record.reference.kind) else {
437 return invalid_record(format!(
438 "plugin {owner} did not register schema for owned record kind {}",
439 record.reference.kind
440 ));
441 };
442 if schema_owner != owner || record.class != schema.class {
443 return invalid_record(format!(
444 "domain record {} disagrees with its registered owner or class",
445 record.reference
446 ));
447 }
448 schema.payload_schema.validate(&record.payload)?;
449 validate_record_references(record, schema, records, core_exists)?;
450 validate_successor(record, records)?;
451 }
452 validate_successor_graph(records)?;
453 Ok(())
454}
455
456pub(crate) fn apply_mutation_bundle(
457 records: &BTreeMap<DomainRecordRef, DomainRecord>,
458 schemas: &DomainRecordSchemas,
459 now: SimTime,
460 core_exists: &dyn Fn(&EntityRef) -> bool,
461 mut requests: Vec<DomainMutationRequest<'_>>,
462) -> Result<
463 (
464 BTreeMap<DomainRecordRef, DomainRecord>,
465 Vec<DomainRecordChange>,
466 ),
467 CanwuError,
468> {
469 requests.sort_by(|left, right| left.mutation.target().cmp(right.mutation.target()));
470 if requests
471 .windows(2)
472 .any(|pair| pair[0].mutation.target() == pair[1].mutation.target())
473 {
474 return invalid_record("a boundary cannot mutate the same domain record twice");
475 }
476 let created_records = requests
477 .iter()
478 .filter_map(|request| match request.mutation {
479 DomainRecordMutation::Create { record } => Some(record.reference.clone()),
480 DomainRecordMutation::Update { .. }
481 | DomainRecordMutation::Retire { .. }
482 | DomainRecordMutation::Delete { .. } => None,
483 })
484 .collect::<BTreeSet<_>>();
485
486 let mut next = records.clone();
487 let mut changes = Vec::with_capacity(requests.len());
488 for request in requests {
489 if !canonical_text(request.summary) {
490 return invalid_record("domain record mutations require a canonical summary");
491 }
492 let target = request.mutation.target();
493 validate_reference(target)?;
494 let Some((owner, schema)) = schemas.get(&target.kind) else {
495 return invalid_record(format!(
496 "domain record kind {} has no registered schema",
497 target.kind
498 ));
499 };
500 if owner != request.plugin {
501 return Err(CanwuError::new(
502 ErrorCode::UndeclaredStateWrite,
503 format!(
504 "plugin {} cannot mutate domain record kind {} owned by {owner}",
505 request.plugin, target.kind
506 ),
507 ));
508 }
509 if schema.mutation_policy == DomainRecordMutationPolicy::CreateOnly
510 && !matches!(request.mutation, DomainRecordMutation::Create { .. })
511 {
512 return invalid_record(format!("domain record kind {} is create-only", target.kind));
513 }
514
515 let (operation, previous, current) = match request.mutation {
516 DomainRecordMutation::Create { record } => {
517 let mut record = record.clone();
518 record.canonicalize();
519 if next.contains_key(&record.reference) {
520 return Err(CanwuError::new(
521 ErrorCode::DuplicateDomainRecord,
522 format!("domain record {} already exists", record.reference),
523 ));
524 }
525 let current = DomainRecord {
526 reference: record.reference,
527 owner: owner.clone(),
528 class: schema.class,
529 version: 1,
530 lifecycle: DomainRecordLifecycle::Active,
531 payload: record.payload,
532 references: record.references,
533 };
534 next.insert(current.reference.clone(), current.clone());
535 (DomainRecordOperation::Created, None, current)
536 }
537 DomainRecordMutation::Update {
538 record,
539 expected_version,
540 } => {
541 let mut draft = record.clone();
542 draft.canonicalize();
543 let previous = require_mutable_record(&next, target, *expected_version)?.clone();
544 let version = next_record_version(previous.version)?;
545 let current = DomainRecord {
546 reference: draft.reference,
547 owner: previous.owner.clone(),
548 class: previous.class,
549 version,
550 lifecycle: DomainRecordLifecycle::Active,
551 payload: draft.payload,
552 references: draft.references,
553 };
554 next.insert(current.reference.clone(), current.clone());
555 (DomainRecordOperation::Updated, Some(previous), current)
556 }
557 DomainRecordMutation::Retire {
558 record,
559 expected_version,
560 successor,
561 } => {
562 let previous = require_mutable_record(&next, record, *expected_version)?.clone();
563 validate_new_successor(record, successor.as_ref(), records, &created_records)?;
564 let mut current = previous.clone();
565 current.version = next_record_version(previous.version)?;
566 current.lifecycle = DomainRecordLifecycle::Retired {
567 at: now,
568 successor: successor.clone(),
569 };
570 next.insert(record.clone(), current.clone());
571 (DomainRecordOperation::Retired, Some(previous), current)
572 }
573 DomainRecordMutation::Delete {
574 record,
575 expected_version,
576 } => {
577 let previous = next.get(record).ok_or_else(|| {
578 CanwuError::new(
579 ErrorCode::DomainRecordNotFound,
580 format!("domain record {record} was not found"),
581 )
582 })?;
583 if previous.version != *expected_version {
584 return Err(version_conflict(
585 record,
586 *expected_version,
587 previous.version,
588 ));
589 }
590 if !matches!(previous.lifecycle, DomainRecordLifecycle::Retired { .. }) {
591 return invalid_record("domain records must be retired before deletion");
592 }
593 let previous = previous.clone();
594 let mut current = previous.clone();
595 current.version = next_record_version(previous.version)?;
596 current.lifecycle = DomainRecordLifecycle::Deleted { at: now };
597 current.references.clear();
598 next.insert(record.clone(), current.clone());
599 (DomainRecordOperation::Deleted, Some(previous), current)
600 }
601 };
602 changes.push(DomainRecordChange {
603 plugin: request.plugin.to_owned(),
604 system: request.system.to_owned(),
605 operation,
606 previous,
607 current,
608 visibility: request.visibility,
609 summary: request.summary.to_owned(),
610 });
611 }
612 validate_record_store(&next, schemas, now, core_exists)?;
613 Ok((next, changes))
614}
615
616pub(crate) fn domain_entity_exists(
617 records: &BTreeMap<DomainRecordRef, DomainRecord>,
618 reference: &DomainRecordRef,
619) -> bool {
620 records
621 .get(reference)
622 .is_some_and(|record| record.class == DomainRecordClass::Entity && !record.is_deleted())
623}
624
625pub(crate) fn mutation_from_change(change: &DomainRecordChange) -> DomainRecordMutation {
626 match change.operation {
627 DomainRecordOperation::Created => DomainRecordMutation::Create {
628 record: DomainRecordDraft {
629 reference: change.current.reference.clone(),
630 payload: change.current.payload.clone(),
631 references: change.current.references.clone(),
632 },
633 },
634 DomainRecordOperation::Updated => DomainRecordMutation::Update {
635 record: DomainRecordDraft {
636 reference: change.current.reference.clone(),
637 payload: change.current.payload.clone(),
638 references: change.current.references.clone(),
639 },
640 expected_version: change.previous.as_ref().map_or(0, |record| record.version),
641 },
642 DomainRecordOperation::Retired => DomainRecordMutation::Retire {
643 record: change.current.reference.clone(),
644 expected_version: change.previous.as_ref().map_or(0, |record| record.version),
645 successor: match &change.current.lifecycle {
646 DomainRecordLifecycle::Retired { successor, .. } => successor.clone(),
647 DomainRecordLifecycle::Active | DomainRecordLifecycle::Deleted { .. } => None,
648 },
649 },
650 DomainRecordOperation::Deleted => DomainRecordMutation::Delete {
651 record: change.current.reference.clone(),
652 expected_version: change.previous.as_ref().map_or(0, |record| record.version),
653 },
654 }
655}
656
657fn validate_record_shape(record: &DomainRecord, now: SimTime) -> Result<(), CanwuError> {
658 validate_reference(&record.reference)?;
659 if !canonical_text(&record.owner)
660 || record.version == 0
661 || record.references.windows(2).any(|pair| pair[0] >= pair[1])
662 {
663 return invalid_record(format!(
664 "domain record {} has noncanonical identity, owner, version, or references",
665 record.reference
666 ));
667 }
668 for reference in &record.references {
669 if !canonical_text(&reference.role) {
670 return invalid_record("domain record reference roles must be canonical");
671 }
672 validate_target(&reference.target)?;
673 }
674 match &record.lifecycle {
675 DomainRecordLifecycle::Active => {}
676 DomainRecordLifecycle::Retired { at, successor } => {
677 if *at > now {
678 return invalid_record("domain record retirement cannot be future-dated");
679 }
680 if let Some(successor) = successor {
681 validate_reference(successor)?;
682 }
683 }
684 DomainRecordLifecycle::Deleted { at } => {
685 if *at > now || !record.references.is_empty() {
686 return invalid_record(
687 "deleted domain-record tombstones cannot be future-dated or retain references",
688 );
689 }
690 }
691 }
692 Ok(())
693}
694
695fn validate_record_references(
696 record: &DomainRecord,
697 schema: &DomainRecordSchema,
698 records: &BTreeMap<DomainRecordRef, DomainRecord>,
699 core_exists: &dyn Fn(&EntityRef) -> bool,
700) -> Result<(), CanwuError> {
701 if record.is_deleted() {
702 return Ok(());
703 }
704 let mut by_role = BTreeMap::<&str, Vec<&DomainReferenceTarget>>::new();
705 for reference in &record.references {
706 by_role
707 .entry(reference.role.as_str())
708 .or_default()
709 .push(&reference.target);
710 }
711 for reference_schema in &schema.references {
712 let targets = by_role
713 .remove(reference_schema.role.as_str())
714 .unwrap_or_default();
715 if (reference_schema.required && targets.is_empty())
716 || (!reference_schema.multiple && targets.len() > 1)
717 {
718 return invalid_record(format!(
719 "domain record {} violates reference cardinality for role {}",
720 record.reference, reference_schema.role
721 ));
722 }
723 for target in targets {
724 validate_reference_target(
725 target,
726 reference_schema,
727 records,
728 core_exists,
729 &record.reference,
730 )?;
731 }
732 }
733 if !by_role.is_empty() {
734 return invalid_record(format!(
735 "domain record {} contains undeclared reference roles",
736 record.reference
737 ));
738 }
739 Ok(())
740}
741
742fn validate_reference_target(
743 target: &DomainReferenceTarget,
744 schema: &DomainReferenceSchema,
745 records: &BTreeMap<DomainRecordRef, DomainRecord>,
746 core_exists: &dyn Fn(&EntityRef) -> bool,
747 source: &DomainRecordRef,
748) -> Result<(), CanwuError> {
749 let (kind, is_entity) = match target {
750 DomainReferenceTarget::Core(entity) => {
751 let Some(kind) = entity.core_kind() else {
752 return invalid_record(
753 "domain entities use domain references rather than core-reference aliases",
754 );
755 };
756 if !core_exists(entity) {
757 return invalid_record(format!(
758 "domain record {source} references missing core entity {entity}"
759 ));
760 }
761 (DomainReferenceTargetKind::Core(kind), true)
762 }
763 DomainReferenceTarget::Domain(reference) => {
764 let target_record = records.get(reference).ok_or_else(|| {
765 CanwuError::new(
766 ErrorCode::DomainRecordNotFound,
767 format!("domain record {source} references missing record {reference}"),
768 )
769 })?;
770 if target_record.is_deleted() || (!schema.allow_retired && !target_record.is_active()) {
771 return Err(CanwuError::new(
772 ErrorCode::DomainRecordReferenced,
773 format!("domain record {source} references unavailable record {reference}"),
774 ));
775 }
776 (
777 DomainReferenceTargetKind::Domain(reference.kind.clone()),
778 target_record.class == DomainRecordClass::Entity,
779 )
780 }
781 };
782 if !(schema.targets.contains(&kind)
783 || is_entity
784 && schema
785 .targets
786 .contains(&DomainReferenceTargetKind::AnyEntity))
787 {
788 return invalid_record(format!(
789 "domain record {source} reference target does not match role {}",
790 schema.role
791 ));
792 }
793 Ok(())
794}
795
796fn validate_reference_targets_basic(
797 record: &DomainRecord,
798 records: &BTreeMap<DomainRecordRef, DomainRecord>,
799 core_exists: &dyn Fn(&EntityRef) -> bool,
800) -> Result<(), CanwuError> {
801 for reference in &record.references {
802 match &reference.target {
803 DomainReferenceTarget::Core(entity) => {
804 if entity.core_kind().is_none() || !core_exists(entity) {
805 return invalid_record(format!(
806 "domain record {} references missing core entity {entity}",
807 record.reference
808 ));
809 }
810 }
811 DomainReferenceTarget::Domain(target) => {
812 if records.get(target).is_none_or(DomainRecord::is_deleted) {
813 return invalid_record(format!(
814 "domain record {} references unavailable record {target}",
815 record.reference
816 ));
817 }
818 }
819 }
820 }
821 Ok(())
822}
823
824fn validate_successor(
825 record: &DomainRecord,
826 records: &BTreeMap<DomainRecordRef, DomainRecord>,
827) -> Result<(), CanwuError> {
828 let DomainRecordLifecycle::Retired {
829 successor: Some(successor),
830 ..
831 } = &record.lifecycle
832 else {
833 return Ok(());
834 };
835 let Some(target) = records.get(successor) else {
836 return invalid_record(format!(
837 "retired domain record {} has a missing successor {successor}",
838 record.reference
839 ));
840 };
841 if successor == &record.reference
842 || successor.kind != record.reference.kind
843 || target.is_deleted()
844 {
845 return invalid_record(
846 "domain record successors must be distinct available records of the same kind",
847 );
848 }
849 Ok(())
850}
851
852fn validate_new_successor(
853 record: &DomainRecordRef,
854 successor: Option<&DomainRecordRef>,
855 records: &BTreeMap<DomainRecordRef, DomainRecord>,
856 created_records: &BTreeSet<DomainRecordRef>,
857) -> Result<(), CanwuError> {
858 let Some(successor) = successor else {
859 return Ok(());
860 };
861 if successor == record || successor.kind != record.kind {
862 return invalid_record(
863 "domain record successors must be distinct active records of the same kind",
864 );
865 }
866 if created_records.contains(successor) {
867 return Ok(());
868 }
869 let Some(target) = records.get(successor) else {
870 return invalid_record(format!(
871 "retired domain record {record} has a missing successor {successor}",
872 ));
873 };
874 if !target.is_active() {
875 return invalid_record("new domain record successors must be active when admitted");
876 }
877 Ok(())
878}
879
880fn validate_successor_graph(
881 records: &BTreeMap<DomainRecordRef, DomainRecord>,
882) -> Result<(), CanwuError> {
883 let mut complete = BTreeSet::new();
884 for start in records.keys() {
885 if complete.contains(start) {
886 continue;
887 }
888 let mut visited = BTreeSet::new();
889 let mut path = Vec::new();
890 let mut current = start;
891 loop {
892 if complete.contains(current) {
893 break;
894 }
895 if !visited.insert(current.clone()) {
896 return invalid_record("domain record successor chains cannot contain cycles");
897 }
898 path.push(current.clone());
899 let Some(DomainRecord {
900 lifecycle:
901 DomainRecordLifecycle::Retired {
902 successor: Some(successor),
903 ..
904 },
905 ..
906 }) = records.get(current)
907 else {
908 break;
909 };
910 current = successor;
911 }
912 complete.extend(path);
913 }
914 Ok(())
915}
916
917fn require_mutable_record<'a>(
918 records: &'a BTreeMap<DomainRecordRef, DomainRecord>,
919 reference: &DomainRecordRef,
920 expected_version: u64,
921) -> Result<&'a DomainRecord, CanwuError> {
922 let record = records.get(reference).ok_or_else(|| {
923 CanwuError::new(
924 ErrorCode::DomainRecordNotFound,
925 format!("domain record {reference} was not found"),
926 )
927 })?;
928 if record.version != expected_version {
929 return Err(version_conflict(
930 reference,
931 expected_version,
932 record.version,
933 ));
934 }
935 if !record.is_active() {
936 return invalid_record("only active domain records can be updated or retired");
937 }
938 Ok(record)
939}
940
941fn next_record_version(version: u64) -> Result<u64, CanwuError> {
942 version.checked_add(1).ok_or_else(|| {
943 CanwuError::new(
944 ErrorCode::IdentifierExhausted,
945 "domain record version space is exhausted",
946 )
947 })
948}
949
950fn version_conflict(reference: &DomainRecordRef, expected: u64, actual: u64) -> CanwuError {
951 CanwuError::new(
952 ErrorCode::DomainRecordVersionConflict,
953 format!(
954 "domain record {reference} expected version {expected}, but current version is {actual}"
955 ),
956 )
957}
958
959fn validate_target(target: &DomainReferenceTarget) -> Result<(), CanwuError> {
960 match target {
961 DomainReferenceTarget::Core(entity) => {
962 if entity.core_kind().is_none() {
963 return invalid_record(
964 "domain entities must use domain-record references in record fields",
965 );
966 }
967 Ok(())
968 }
969 DomainReferenceTarget::Domain(reference) => validate_reference(reference),
970 }
971}
972
973fn validate_reference(reference: &DomainRecordRef) -> Result<(), CanwuError> {
974 validate_kind(&reference.kind)?;
975 if !canonical_text(&reference.id) {
976 return invalid_record("domain record IDs must be non-empty canonical strings");
977 }
978 Ok(())
979}
980
981fn validate_kind(kind: &DomainRecordKind) -> Result<(), CanwuError> {
982 if !canonical_text(&kind.namespace) || !canonical_text(&kind.name) {
983 return invalid_record("domain record kinds require canonical namespace and name values");
984 }
985 Ok(())
986}
987
988fn canonical_text(value: &str) -> bool {
989 !value.is_empty() && value == value.trim()
990}
991
992fn invalid_record<T>(message: impl Into<String>) -> Result<T, CanwuError> {
993 Err(CanwuError::new(ErrorCode::InvalidDomainRecord, message))
994}
995
996#[cfg(test)]
997mod tests {
998 use super::*;
999 use serde_json::json;
1000
1001 fn fixture_kind(name: &str) -> DomainRecordKind {
1002 DomainRecordKind::new("fixture.records", name)
1003 }
1004
1005 fn fixture_record(reference: DomainRecordRef, class: DomainRecordClass) -> DomainRecord {
1006 DomainRecord {
1007 reference,
1008 owner: "fixture".to_owned(),
1009 class,
1010 version: 1,
1011 lifecycle: DomainRecordLifecycle::Active,
1012 payload: json!(null),
1013 references: vec![],
1014 }
1015 }
1016
1017 fn mutation_request(mutation: &DomainRecordMutation) -> DomainMutationRequest<'_> {
1018 DomainMutationRequest {
1019 plugin: "fixture",
1020 system: "fixture-system",
1021 visibility: StateVisibility::NextBoundary,
1022 mutation,
1023 summary: "fixture mutation",
1024 }
1025 }
1026
1027 #[test]
1028 fn create_only_policy_rejects_raw_mutations_and_preserves_create() {
1029 let kind = fixture_kind("immutable");
1030 let existing_ref = DomainRecordRef::new(&kind.namespace, &kind.name, "existing");
1031 let existing = fixture_record(existing_ref.clone(), DomainRecordClass::Record);
1032 let records = BTreeMap::from([(existing_ref.clone(), existing.clone())]);
1033 let mut schema = DomainRecordSchema::new(kind.clone(), DomainRecordClass::Record);
1034 schema.mutation_policy = DomainRecordMutationPolicy::CreateOnly;
1035 let schemas = BTreeMap::from([(kind.clone(), ("fixture".to_owned(), schema))]);
1036 let core_exists = |_: &EntityRef| true;
1037
1038 let update = DomainRecordMutation::Update {
1039 record: DomainRecordDraft::new(existing_ref.clone(), json!("changed")),
1040 expected_version: 1,
1041 };
1042 let retire = DomainRecordMutation::Retire {
1043 record: existing_ref.clone(),
1044 expected_version: 1,
1045 successor: None,
1046 };
1047 let delete = DomainRecordMutation::Delete {
1048 record: existing_ref.clone(),
1049 expected_version: 1,
1050 };
1051 for mutation in [&update, &retire, &delete] {
1052 let error = apply_mutation_bundle(
1053 &records,
1054 &schemas,
1055 SimTime::EPOCH,
1056 &core_exists,
1057 vec![mutation_request(mutation)],
1058 )
1059 .expect_err("create-only kinds must reject non-create raw mutations");
1060 assert_eq!(error.code, ErrorCode::InvalidDomainRecord);
1061 assert_eq!(records.get(&existing_ref), Some(&existing));
1062 }
1063
1064 let new_ref = DomainRecordRef::new(&kind.namespace, &kind.name, "new");
1065 let create = DomainRecordMutation::Create {
1066 record: DomainRecordDraft::new(new_ref.clone(), json!(null)),
1067 };
1068 let (next, changes) = apply_mutation_bundle(
1069 &records,
1070 &schemas,
1071 SimTime::EPOCH,
1072 &core_exists,
1073 vec![mutation_request(&create)],
1074 )
1075 .expect("create-only kinds must still accept creates");
1076 assert!(next.contains_key(&new_ref));
1077 assert_eq!(changes[0].operation, DomainRecordOperation::Created);
1078 }
1079
1080 #[test]
1081 fn any_entity_domain_reference_is_typed_by_registered_record_class() {
1082 let entity_kind = fixture_kind("future-entity");
1083 let value_kind = fixture_kind("future-value");
1084 let entity_ref = DomainRecordRef::new(&entity_kind.namespace, &entity_kind.name, "one");
1085 let value_ref = DomainRecordRef::new(&value_kind.namespace, &value_kind.name, "one");
1086 let records = BTreeMap::from([
1087 (
1088 entity_ref.clone(),
1089 fixture_record(entity_ref.clone(), DomainRecordClass::Entity),
1090 ),
1091 (
1092 value_ref.clone(),
1093 fixture_record(value_ref.clone(), DomainRecordClass::Record),
1094 ),
1095 ]);
1096 let schema = DomainReferenceSchema {
1097 role: "target".to_owned(),
1098 targets: vec![DomainReferenceTargetKind::AnyEntity],
1099 required: true,
1100 multiple: false,
1101 allow_retired: false,
1102 };
1103 let source = DomainRecordRef::new("fixture.records", "source", "one");
1104
1105 assert!(
1106 validate_reference_target(
1107 &DomainReferenceTarget::Domain(entity_ref),
1108 &schema,
1109 &records,
1110 &|_: &EntityRef| false,
1111 &source,
1112 )
1113 .is_ok()
1114 );
1115 let error = validate_reference_target(
1116 &DomainReferenceTarget::Domain(value_ref),
1117 &schema,
1118 &records,
1119 &|_: &EntityRef| false,
1120 &source,
1121 )
1122 .expect_err("AnyEntity must reject domain value records");
1123 assert_eq!(error.code, ErrorCode::InvalidDomainRecord);
1124 }
1125}