Skip to main content

canwu_sim/runtime/
records.rs

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}