1use super::{
2 CanwuError, CauseRef, DomainRecordChange, DomainRecordMutation, DomainRecordRef, EntityRef,
3 ErrorCode, IngressClass, IngressPayload, IngressReceipt, PersistentDomainRecordStore,
4 PluginRegistry, SimTime, Simulation, StateVisibility, canonical_hash, canonical_text,
5 is_canonical_hash, records, runtime_entity_exists,
6};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::panic::{AssertUnwindSafe, catch_unwind};
10
11pub const OWNER_AUTHORIZED_MAINTENANCE_FORMAT_VERSION: u32 = 1;
12pub const MAX_OWNER_AUTHORIZED_PARTICIPANTS: usize = 32;
13pub const MAX_OWNER_AUTHORIZED_MUTATIONS: usize = 256;
14pub(super) const OWNER_AUTHORIZED_MAINTENANCE_SYSTEM: &str = "owner-authorized-maintenance";
15
16#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum OwnerAuthorizedParticipantRole {
19 TargetOwner,
20 DependentOwner,
21}
22
23#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
24#[serde(deny_unknown_fields)]
25pub struct OwnerAuthorizedRecordExpectation {
26 pub record: DomainRecordRef,
27 pub version: u64,
28}
29
30#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
31#[serde(deny_unknown_fields)]
32pub struct OwnerAuthorizedMutation {
33 pub mutation: DomainRecordMutation,
34 pub visibility: StateVisibility,
35 pub summary: String,
36}
37
38#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
39#[serde(deny_unknown_fields)]
40pub struct OwnerAuthorizedParticipantDraft {
41 pub plugin: String,
42 pub role: OwnerAuthorizedParticipantRole,
43 pub accepted: bool,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub rejection_reason: Option<String>,
46 pub expected_records: Vec<OwnerAuthorizedRecordExpectation>,
47 pub mutations: Vec<OwnerAuthorizedMutation>,
48}
49
50#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
51#[serde(deny_unknown_fields)]
52pub struct OwnerAuthorizedMaintenanceRequest {
53 pub request_id: String,
54 pub target: OwnerAuthorizedRecordExpectation,
55 pub requested_at: SimTime,
56 #[serde(default)]
57 pub payload: Value,
58}
59
60#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
61#[serde(deny_unknown_fields)]
62pub struct OwnerAuthorizedMaintenanceDraft {
63 pub request_id: String,
64 pub target: OwnerAuthorizedRecordExpectation,
65 pub requested_at: SimTime,
66 pub participants: Vec<OwnerAuthorizedParticipantDraft>,
67}
68
69#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
70#[serde(deny_unknown_fields)]
71pub struct OwnerAuthorizedParticipantProposal {
72 pub plugin: String,
73 pub semantic_hash: String,
74 pub role: OwnerAuthorizedParticipantRole,
75 pub accepted: bool,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub rejection_reason: Option<String>,
78 pub expected_records: Vec<OwnerAuthorizedRecordExpectation>,
79 pub mutations: Vec<OwnerAuthorizedMutation>,
80 pub proposal_commitment: String,
81}
82
83#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
84#[serde(deny_unknown_fields)]
85pub struct VerifiedOwnerAuthorizedMaintenanceCommit {
86 format_version: u32,
87 request_id: String,
88 target: OwnerAuthorizedRecordExpectation,
89 requested_at: SimTime,
90 source_domain_root: String,
91 participants: Vec<OwnerAuthorizedParticipantProposal>,
92 token: String,
93}
94
95impl VerifiedOwnerAuthorizedMaintenanceCommit {
96 #[must_use]
97 pub(super) fn token(&self) -> &str {
98 &self.token
99 }
100
101 pub(super) fn source_root(&self) -> &str {
102 &self.source_domain_root
103 }
104}
105
106impl Simulation {
107 pub fn schedule_owner_authorized_maintenance(
110 &mut self,
111 due_at: SimTime,
112 priority: i32,
113 request: OwnerAuthorizedMaintenanceRequest,
114 ) -> Result<IngressReceipt, CanwuError> {
115 let commit = self.prepare_owner_authorized_maintenance(request)?;
116 self.enqueue_owner_authorized_maintenance(due_at, priority, commit)
117 }
118
119 fn prepare_owner_authorized_maintenance(
124 &self,
125 request: OwnerAuthorizedMaintenanceRequest,
126 ) -> Result<VerifiedOwnerAuthorizedMaintenanceCommit, CanwuError> {
127 self.ensure_runtime_ready()?;
128 if !canonical_text(&request.request_id) || request.requested_at != self.time() {
129 return Err(invalid_maintenance(
130 "owner-authorized maintenance identity or request time is invalid",
131 ));
132 }
133 let target_record = self
134 .state
135 .current
136 .domain_records
137 .get(&request.target.record)
138 .ok_or_else(|| invalid_maintenance("maintenance target record is unavailable"))?;
139 if target_record.version != request.target.version || !target_record.is_active() {
140 return Err(invalid_maintenance(
141 "maintenance target expectation is stale or not active",
142 ));
143 }
144 let (target_owner, _) = self
145 .plugins
146 .record_schemas
147 .get(&request.target.record.kind)
148 .ok_or_else(|| invalid_maintenance("maintenance target schema has no owner"))?;
149 let mut required = self
150 .plugins
151 .maintenance_dependency_resolvers
152 .get(&request.target.record.kind.namespace)
153 .cloned()
154 .unwrap_or_default();
155 required.insert(target_owner.clone());
156 if required.is_empty() || required.len() > MAX_OWNER_AUTHORIZED_PARTICIPANTS {
157 return Err(invalid_maintenance(
158 "owner-authorized maintenance participant budget is invalid",
159 ));
160 }
161
162 let mut participant_drafts = Vec::with_capacity(required.len());
163 for plugin in &required {
164 let descriptor = self.plugins.descriptors.get(plugin).ok_or_else(|| {
165 invalid_maintenance("maintenance participant has no plugin descriptor")
166 })?;
167 if !descriptor.owner_authorized_maintenance_participant {
168 return Err(invalid_maintenance(
169 "maintenance participant descriptor lacks an owner callback",
170 ));
171 }
172 let handler = self
173 .plugins
174 .maintenance_participants
175 .get(plugin)
176 .copied()
177 .ok_or_else(|| {
178 invalid_maintenance("maintenance participant callback is unavailable")
179 })?;
180 let role = if plugin == target_owner {
181 OwnerAuthorizedParticipantRole::TargetOwner
182 } else {
183 OwnerAuthorizedParticipantRole::DependentOwner
184 };
185 let reads = descriptor
186 .record_schemas
187 .iter()
188 .map(records::DomainRecordSchema::state_key)
189 .collect::<Vec<_>>();
190 let proposal = catch_unwind(AssertUnwindSafe(|| {
191 handler(&self.plugin_view(plugin, &reads), &request, role)
192 }))
193 .map_err(|_| {
194 CanwuError::new(
195 ErrorCode::PluginPanicked,
196 format!("maintenance participant {plugin} panicked"),
197 )
198 })??;
199 if proposal.plugin != *plugin || proposal.role != role {
200 return Err(invalid_maintenance(
201 "maintenance callback returned the wrong participant identity or role",
202 ));
203 }
204 participant_drafts.push(proposal);
205 }
206 if participant_drafts
207 .iter()
208 .map(|proposal| proposal.mutations.len())
209 .sum::<usize>()
210 > MAX_OWNER_AUTHORIZED_MUTATIONS
211 {
212 return Err(invalid_maintenance(
213 "owner-authorized maintenance mutation budget is invalid",
214 ));
215 }
216 let mut draft = OwnerAuthorizedMaintenanceDraft {
217 request_id: request.request_id,
218 target: request.target,
219 requested_at: request.requested_at,
220 participants: participant_drafts,
221 };
222 draft
223 .participants
224 .sort_by(|left, right| left.plugin.cmp(&right.plugin));
225
226 let mut participants = Vec::with_capacity(draft.participants.len());
227 for mut proposal in draft.participants {
228 let descriptor = self
229 .plugins
230 .descriptors
231 .get(&proposal.plugin)
232 .ok_or_else(|| {
233 invalid_maintenance("maintenance participant has no plugin descriptor")
234 })?;
235 let expected_role = if proposal.plugin == *target_owner {
236 OwnerAuthorizedParticipantRole::TargetOwner
237 } else {
238 OwnerAuthorizedParticipantRole::DependentOwner
239 };
240 if proposal.role != expected_role
241 || !proposal.accepted
242 || proposal.rejection_reason.is_some()
243 {
244 return Err(invalid_maintenance(
245 "maintenance participant rejected or claimed the wrong owner role",
246 ));
247 }
248 proposal.expected_records.sort();
249 proposal.expected_records.dedup();
250 if proposal.expected_records.is_empty()
251 || proposal
252 .expected_records
253 .iter()
254 .any(|expected| expected.version == 0)
255 {
256 return Err(invalid_maintenance(
257 "maintenance participant requires exact nonzero record expectations",
258 ));
259 }
260 for expected in &proposal.expected_records {
261 let record = self
262 .state
263 .current
264 .domain_records
265 .get(&expected.record)
266 .ok_or_else(|| {
267 invalid_maintenance("maintenance expected record is unavailable")
268 })?;
269 if record.version != expected.version {
270 return Err(invalid_maintenance(
271 "maintenance participant record expectation is stale",
272 ));
273 }
274 }
275 for change in &proposal.mutations {
276 if !canonical_text(&change.summary) {
277 return Err(invalid_maintenance(
278 "maintenance mutation summary is not canonical",
279 ));
280 }
281 let owner = self
282 .plugins
283 .record_schemas
284 .get(&change.mutation.target().kind)
285 .map(|(owner, _)| owner)
286 .ok_or_else(|| {
287 invalid_maintenance("maintenance mutation target has no schema owner")
288 })?;
289 if owner != &proposal.plugin {
290 return Err(CanwuError::new(
291 ErrorCode::UndeclaredStateWrite,
292 "owner-authorized proposal targets another plugin's schema",
293 ));
294 }
295 }
296 if proposal.role == OwnerAuthorizedParticipantRole::TargetOwner
297 && !proposal.mutations.iter().any(|change| {
298 matches!(
299 &change.mutation,
300 DomainRecordMutation::Update {
301 record,
302 expected_version,
303 } if record.reference == draft.target.record
304 && *expected_version == draft.target.version
305 ) || matches!(
306 &change.mutation,
307 DomainRecordMutation::Retire { record, expected_version, .. }
308 if record == &draft.target.record
309 && *expected_version == draft.target.version
310 ) || matches!(
311 &change.mutation,
312 DomainRecordMutation::Delete { record, expected_version }
313 if record == &draft.target.record
314 && *expected_version == draft.target.version
315 )
316 })
317 {
318 return Err(invalid_maintenance(
319 "target owner did not supply an exact owner-defined target mutation",
320 ));
321 }
322 let proposal_commitment =
323 participant_commitment(&proposal, &descriptor.semantic_hash, &draft.target)?;
324 participants.push(OwnerAuthorizedParticipantProposal {
325 plugin: proposal.plugin,
326 semantic_hash: descriptor.semantic_hash.clone(),
327 role: proposal.role,
328 accepted: proposal.accepted,
329 rejection_reason: proposal.rejection_reason,
330 expected_records: proposal.expected_records,
331 mutations: proposal.mutations,
332 proposal_commitment,
333 });
334 }
335 let source_domain_root = canonical_hash(
336 "canwu.owner-authorized.source-domain-root.v1",
337 self.state.current.domain_records.roots(),
338 )?;
339 let token = canonical_hash(
340 "canwu.owner-authorized.maintenance-token.v1",
341 &(
342 OWNER_AUTHORIZED_MAINTENANCE_FORMAT_VERSION,
343 &draft.request_id,
344 &draft.target,
345 draft.requested_at,
346 &source_domain_root,
347 &participants,
348 ),
349 )?;
350 let commit = VerifiedOwnerAuthorizedMaintenanceCommit {
351 format_version: OWNER_AUTHORIZED_MAINTENANCE_FORMAT_VERSION,
352 request_id: draft.request_id,
353 target: draft.target,
354 requested_at: draft.requested_at,
355 source_domain_root,
356 participants,
357 token,
358 };
359 let _ = self.apply_owner_authorized_commit_to_root(&commit)?;
360 Ok(commit)
361 }
362
363 pub(super) fn enqueue_owner_authorized_maintenance(
364 &mut self,
365 due_at: SimTime,
366 priority: i32,
367 commit: VerifiedOwnerAuthorizedMaintenanceCommit,
368 ) -> Result<IngressReceipt, CanwuError> {
369 self.ensure_runtime_ready()?;
370 self.ensure_canonical_ingress_can_start()?;
371 let _ = self.apply_owner_authorized_commit_to_root(&commit)?;
372 for record in &self.state.evidence.ingress {
373 let IngressPayload::Maintenance { request } = &record.payload else {
374 continue;
375 };
376 if let super::MaintenanceIngressRequest::OwnerAuthorized { commit: existing } =
377 request.as_ref()
378 && existing.token() == commit.token()
379 {
380 if existing == &commit && record.due_at == due_at && record.priority == priority {
381 return Ok(IngressReceipt {
382 ingress_id: record.id,
383 issued_at: record.issued_at,
384 due_at: record.due_at,
385 });
386 }
387 return Err(CanwuError::new(
388 ErrorCode::IdempotencyConflict,
389 "owner-authorized maintenance token is already queued differently",
390 ));
391 }
392 }
393 self.append_ingress(
394 due_at,
395 IngressClass::ScheduledSystem,
396 priority,
397 IngressPayload::Maintenance {
398 request: Box::new(super::MaintenanceIngressRequest::OwnerAuthorized { commit }),
399 },
400 Some(CauseRef::System(
401 "canwu.core.owner-authorized-maintenance".to_owned(),
402 )),
403 false,
404 )
405 }
406
407 pub(super) fn apply_owner_authorized_maintenance(
408 &mut self,
409 commit: &VerifiedOwnerAuthorizedMaintenanceCommit,
410 ) -> Result<Vec<super::DomainRecordChange>, CanwuError> {
411 let (next, changes) = self.apply_owner_authorized_commit_to_root(commit)?;
412 self.state.current.domain_records = next;
413 self.invalidate_commitments(super::CommitmentDomains::DOMAIN_RECORDS);
414 Ok(changes)
415 }
416
417 fn apply_owner_authorized_commit_to_root(
418 &self,
419 commit: &VerifiedOwnerAuthorizedMaintenanceCommit,
420 ) -> Result<(PersistentDomainRecordStore, Vec<DomainRecordChange>), CanwuError> {
421 apply_verified_owner_authorized_commit(
422 commit,
423 &self.state.current.domain_records,
424 &self.plugins,
425 self.state.scheduler.now,
426 &|entity| runtime_entity_exists(&self.state, entity),
427 )
428 }
429}
430
431pub(super) fn apply_verified_owner_authorized_commit(
432 commit: &VerifiedOwnerAuthorizedMaintenanceCommit,
433 current: &PersistentDomainRecordStore,
434 plugins: &PluginRegistry,
435 now: SimTime,
436 core_exists: &dyn Fn(&EntityRef) -> bool,
437) -> Result<(PersistentDomainRecordStore, Vec<DomainRecordChange>), CanwuError> {
438 validate_verified_commit_authorization(commit, current, plugins)?;
439 let source_domain_root = canonical_hash(
440 "canwu.owner-authorized.source-domain-root.v1",
441 current.roots(),
442 )?;
443 if source_domain_root != commit.source_domain_root {
444 return Err(invalid_maintenance(
445 "owner-authorized maintenance source root is stale",
446 ));
447 }
448 let mut mutations = Vec::new();
449 for proposal in &commit.participants {
450 mutations.extend(
451 proposal
452 .mutations
453 .iter()
454 .map(|change| records::DomainMutationRequest {
455 plugin: proposal.plugin.as_str(),
456 system: OWNER_AUTHORIZED_MAINTENANCE_SYSTEM,
457 visibility: change.visibility,
458 mutation: &change.mutation,
459 summary: &change.summary,
460 }),
461 );
462 }
463 records::apply_mutation_bundle_cow(
464 current,
465 &plugins.record_schemas,
466 now,
467 core_exists,
468 mutations,
469 )
470}
471
472fn validate_verified_commit_authorization(
473 commit: &VerifiedOwnerAuthorizedMaintenanceCommit,
474 current: &PersistentDomainRecordStore,
475 plugins: &PluginRegistry,
476) -> Result<(), CanwuError> {
477 validate_verified_commit_authorization_structure(commit, plugins)?;
478 validate_verified_commit_freshness(commit, current)
479}
480
481pub(super) fn validate_verified_commit_authorization_structure(
482 commit: &VerifiedOwnerAuthorizedMaintenanceCommit,
483 plugins: &PluginRegistry,
484) -> Result<(), CanwuError> {
485 validate_verified_commit_shape(commit)?;
486 if commit.target.version == 0 {
487 return Err(invalid_maintenance(
488 "maintenance target expectation must use a nonzero version",
489 ));
490 }
491 let (target_owner, _) = plugins
492 .record_schemas
493 .get(&commit.target.record.kind)
494 .ok_or_else(|| invalid_maintenance("maintenance target schema has no owner"))?;
495 let mut required = plugins
496 .maintenance_dependency_resolvers
497 .get(&commit.target.record.kind.namespace)
498 .cloned()
499 .unwrap_or_default();
500 required.insert(target_owner.clone());
501 if required.is_empty()
502 || required.len() > MAX_OWNER_AUTHORIZED_PARTICIPANTS
503 || commit.participants.len() != required.len()
504 || !commit
505 .participants
506 .iter()
507 .map(|proposal| &proposal.plugin)
508 .eq(required.iter())
509 {
510 return Err(invalid_maintenance(
511 "owner-authorized maintenance participant set is incomplete or noncanonical",
512 ));
513 }
514 let mutation_count = commit
515 .participants
516 .iter()
517 .try_fold(0_usize, |total, proposal| {
518 total.checked_add(proposal.mutations.len()).ok_or_else(|| {
519 invalid_maintenance("owner-authorized maintenance mutation budget is invalid")
520 })
521 })?;
522 if mutation_count > MAX_OWNER_AUTHORIZED_MUTATIONS {
523 return Err(invalid_maintenance(
524 "owner-authorized maintenance mutation budget is invalid",
525 ));
526 }
527 for proposal in &commit.participants {
528 let descriptor = plugins.descriptors.get(&proposal.plugin).ok_or_else(|| {
529 invalid_maintenance("maintenance participant descriptor is unavailable")
530 })?;
531 let expected_role = if proposal.plugin == *target_owner {
532 OwnerAuthorizedParticipantRole::TargetOwner
533 } else {
534 OwnerAuthorizedParticipantRole::DependentOwner
535 };
536 if !descriptor.owner_authorized_maintenance_participant
537 || descriptor.semantic_hash != proposal.semantic_hash
538 || proposal.role != expected_role
539 || !proposal.accepted
540 || proposal.rejection_reason.is_some()
541 || participant_commitment_from_verified(proposal, &commit.target)?
542 != proposal.proposal_commitment
543 {
544 return Err(invalid_maintenance(
545 "maintenance participant identity, role, or commitment changed",
546 ));
547 }
548 if proposal.expected_records.is_empty()
549 || proposal
550 .expected_records
551 .windows(2)
552 .any(|pair| pair[0] >= pair[1])
553 || proposal
554 .expected_records
555 .iter()
556 .any(|expected| expected.version == 0)
557 {
558 return Err(invalid_maintenance(
559 "maintenance participant expectations are empty or noncanonical",
560 ));
561 }
562 for change in &proposal.mutations {
563 if !canonical_text(&change.summary) {
564 return Err(invalid_maintenance(
565 "maintenance mutation summary is not canonical",
566 ));
567 }
568 let owner = plugins
569 .record_schemas
570 .get(&change.mutation.target().kind)
571 .map(|(owner, _)| owner)
572 .ok_or_else(|| {
573 invalid_maintenance("maintenance mutation target has no schema owner")
574 })?;
575 if owner != &proposal.plugin {
576 return Err(CanwuError::new(
577 ErrorCode::UndeclaredStateWrite,
578 "owner-authorized proposal targets another plugin's schema",
579 ));
580 }
581 }
582 if proposal.role == OwnerAuthorizedParticipantRole::TargetOwner
583 && !proposal.mutations.iter().any(|change| {
584 matches!(
585 &change.mutation,
586 DomainRecordMutation::Update {
587 record,
588 expected_version,
589 } if record.reference == commit.target.record
590 && *expected_version == commit.target.version
591 ) || matches!(
592 &change.mutation,
593 DomainRecordMutation::Retire { record, expected_version, .. }
594 if record == &commit.target.record
595 && *expected_version == commit.target.version
596 ) || matches!(
597 &change.mutation,
598 DomainRecordMutation::Delete { record, expected_version }
599 if record == &commit.target.record
600 && *expected_version == commit.target.version
601 )
602 })
603 {
604 return Err(invalid_maintenance(
605 "target owner did not supply an exact owner-defined target mutation",
606 ));
607 }
608 }
609 Ok(())
610}
611
612fn validate_verified_commit_freshness(
613 commit: &VerifiedOwnerAuthorizedMaintenanceCommit,
614 current: &PersistentDomainRecordStore,
615) -> Result<(), CanwuError> {
616 let target_record = current
617 .get(&commit.target.record)
618 .ok_or_else(|| invalid_maintenance("maintenance target record is unavailable"))?;
619 if target_record.version != commit.target.version || !target_record.is_active() {
620 return Err(invalid_maintenance(
621 "maintenance target expectation is stale or not active",
622 ));
623 }
624 for proposal in &commit.participants {
625 for expected in &proposal.expected_records {
626 if current
627 .get(&expected.record)
628 .is_none_or(|record| record.version != expected.version)
629 {
630 return Err(invalid_maintenance(
631 "owner-authorized maintenance expectation is stale",
632 ));
633 }
634 }
635 }
636 Ok(())
637}
638
639fn participant_commitment(
640 proposal: &OwnerAuthorizedParticipantDraft,
641 semantic_hash: &str,
642 target: &OwnerAuthorizedRecordExpectation,
643) -> Result<String, CanwuError> {
644 canonical_hash(
645 "canwu.owner-authorized.participant.v1",
646 &(
647 &proposal.plugin,
648 semantic_hash,
649 proposal.role,
650 proposal.accepted,
651 &proposal.rejection_reason,
652 &proposal.expected_records,
653 &proposal.mutations,
654 target,
655 ),
656 )
657}
658
659fn participant_commitment_from_verified(
660 proposal: &OwnerAuthorizedParticipantProposal,
661 target: &OwnerAuthorizedRecordExpectation,
662) -> Result<String, CanwuError> {
663 canonical_hash(
664 "canwu.owner-authorized.participant.v1",
665 &(
666 &proposal.plugin,
667 &proposal.semantic_hash,
668 proposal.role,
669 proposal.accepted,
670 &proposal.rejection_reason,
671 &proposal.expected_records,
672 &proposal.mutations,
673 target,
674 ),
675 )
676}
677
678pub(super) fn validate_verified_commit_shape(
679 commit: &VerifiedOwnerAuthorizedMaintenanceCommit,
680) -> Result<(), CanwuError> {
681 if commit.format_version != OWNER_AUTHORIZED_MAINTENANCE_FORMAT_VERSION
682 || !canonical_text(&commit.request_id)
683 || !is_canonical_hash(&commit.source_domain_root)
684 || !is_canonical_hash(&commit.token)
685 || commit.participants.is_empty()
686 || commit.participants.len() > MAX_OWNER_AUTHORIZED_PARTICIPANTS
687 || commit
688 .participants
689 .windows(2)
690 .any(|pair| pair[0].plugin >= pair[1].plugin)
691 || commit.participants.iter().any(|proposal| {
692 !proposal.accepted
693 || proposal.rejection_reason.is_some()
694 || !is_canonical_hash(&proposal.semantic_hash)
695 || !is_canonical_hash(&proposal.proposal_commitment)
696 })
697 {
698 return Err(invalid_maintenance(
699 "owner-authorized maintenance commit is malformed or non-canonical",
700 ));
701 }
702 let expected = canonical_hash(
703 "canwu.owner-authorized.maintenance-token.v1",
704 &(
705 commit.format_version,
706 &commit.request_id,
707 &commit.target,
708 commit.requested_at,
709 &commit.source_domain_root,
710 &commit.participants,
711 ),
712 )?;
713 if expected != commit.token {
714 return Err(invalid_maintenance(
715 "owner-authorized maintenance token is inconsistent",
716 ));
717 }
718 Ok(())
719}
720
721fn invalid_maintenance(message: impl Into<String>) -> CanwuError {
722 CanwuError::new(ErrorCode::InvalidDomainRecord, message)
723}