Skip to main content

canwu_sim/
records.rs

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}