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