Skip to main content

lodestone_client/
development_workflows.rs

1//! Closed recovery models for supported local development workflows.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Component, Path, PathBuf};
5
6use lodestone_wire::{
7    Digest, Identity, IndexResourceSpec, LODESTONE_API_INDEX_RESOURCE_SPEC_FORMAT_VERSION, Locator, Resource,
8    ServiceCredentialReservation, U64,
9};
10use serde::{Deserialize, Serialize};
11
12use crate::{
13    LocalOperatorWorkflowKind, PublishedFileIdentity, WorkflowJournalError, WorkflowJournalIntent,
14    WorkflowJournalTransition,
15};
16
17pub const DEVELOPMENT_MATERIALIZE_FORMAT_VERSION: u16 = 1;
18pub const DEVELOPMENT_PKI_SUCCESSOR_FORMAT_VERSION: u16 = 1;
19pub const DEVELOPMENT_RECONCILE_FORMAT_VERSION: u16 = 1;
20const MAX_ENDPOINT_BYTES: usize = 2_048;
21const MAX_NAME_BYTES: usize = 255;
22
23#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
24#[serde(deny_unknown_fields)]
25pub struct DevelopmentMaterializeIntent {
26    pub format_version: u16,
27    pub output_root: String,
28    pub supply_root: String,
29    pub supply_package_sha256: [u8; 32],
30    pub supply_source_revision: String,
31    pub native_api_sha256: [u8; 32],
32    pub native_endpoint: String,
33    pub subscription_endpoint: String,
34    pub internal_endpoint: String,
35    pub bootstrap_endpoint: String,
36}
37
38impl crate::workflow_journal::sealed::Sealed for DevelopmentMaterializeIntent {}
39
40impl WorkflowJournalIntent for DevelopmentMaterializeIntent {
41    type Transition = DevelopmentMaterializeTransition;
42
43    const KIND: LocalOperatorWorkflowKind = LocalOperatorWorkflowKind::DevelopmentMaterialize;
44
45    fn validate(&self) -> Result<(), WorkflowJournalError> {
46        if self.format_version != DEVELOPMENT_MATERIALIZE_FORMAT_VERSION
47            || !valid_absolute_path(&self.output_root)
48            || !valid_absolute_path(&self.supply_root)
49            || self.output_root == self.supply_root
50            || !valid_sha256(&self.supply_package_sha256)
51            || !valid_git_object(&self.supply_source_revision)
52            || !valid_sha256(&self.native_api_sha256)
53            || !valid_development_endpoints(
54                &self.native_endpoint,
55                &self.subscription_endpoint,
56                &self.internal_endpoint,
57                &self.bootstrap_endpoint,
58            )
59        {
60            return Err(WorkflowJournalError::InvalidIntent);
61        }
62        Ok(())
63    }
64}
65
66#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
67#[serde(rename_all = "snake_case")]
68pub enum DevelopmentMaterializeArtifact {
69    CaCertificate,
70    CaPrivateKey,
71    ProcessCertificate,
72    ProcessPrivateKey,
73    GatewayCertificate,
74    GatewayPrivateKey,
75    BootstrapCredential,
76    BootstrapIntent,
77    ServerConfiguration,
78    DevelopmentIndexDefinition,
79    InstanceManifest,
80}
81
82#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum DevelopmentMaterializePrivatePlan {
85    Pki,
86}
87
88impl DevelopmentMaterializeArtifact {
89    fn is_manifest(self) -> bool {
90        self == Self::InstanceManifest
91    }
92
93    fn required_before_manifest() -> BTreeSet<Self> {
94        [
95            Self::CaCertificate,
96            Self::CaPrivateKey,
97            Self::ProcessCertificate,
98            Self::ProcessPrivateKey,
99            Self::GatewayCertificate,
100            Self::GatewayPrivateKey,
101            Self::BootstrapCredential,
102            Self::BootstrapIntent,
103            Self::ServerConfiguration,
104            Self::DevelopmentIndexDefinition,
105        ]
106        .into_iter()
107        .collect()
108    }
109}
110
111#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
112#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
113pub enum DevelopmentMaterializeTransition {
114    InstanceIdentityRecorded {
115        instance_identity: String,
116    },
117    ClockRecorded {
118        unix_millis: U64,
119    },
120    PrivatePlanStaged {
121        plan: DevelopmentMaterializePrivatePlan,
122        file: PublishedFileIdentity,
123    },
124    PrivatePlanPublished {
125        plan: DevelopmentMaterializePrivatePlan,
126        file: PublishedFileIdentity,
127    },
128    ArtifactStaged {
129        artifact: DevelopmentMaterializeArtifact,
130        file: PublishedFileIdentity,
131    },
132    ArtifactPublished {
133        artifact: DevelopmentMaterializeArtifact,
134        file: PublishedFileIdentity,
135        #[serde(default, skip_serializing_if = "Option::is_none")]
136        public_sha256: Option<[u8; 32]>,
137    },
138    Verified {
139        manifest_sha256: [u8; 32],
140    },
141    Completed,
142}
143
144impl crate::workflow_journal::sealed::Sealed for DevelopmentMaterializeTransition {}
145
146impl WorkflowJournalTransition for DevelopmentMaterializeTransition {
147    fn validate(&self) -> Result<(), WorkflowJournalError> {
148        let valid = match self {
149            Self::InstanceIdentityRecorded { instance_identity } => valid_instance_identity(instance_identity),
150            Self::ClockRecorded { unix_millis } => valid_u64(unix_millis),
151            Self::PrivatePlanStaged { file, .. }
152            | Self::PrivatePlanPublished { file, .. }
153            | Self::ArtifactStaged { file, .. } => file.is_valid(),
154            Self::ArtifactPublished {
155                artifact,
156                file,
157                public_sha256,
158            } => {
159                file.is_valid()
160                    && public_sha256.as_ref().is_none_or(valid_sha256)
161                    && (!artifact.is_manifest() || public_sha256.as_ref().is_some_and(valid_sha256))
162            }
163            Self::Verified { manifest_sha256 } => valid_sha256(manifest_sha256),
164            Self::Completed => true,
165        };
166        if valid {
167            Ok(())
168        } else {
169            Err(WorkflowJournalError::InvalidTransition)
170        }
171    }
172}
173
174#[derive(Clone, Debug, Default, PartialEq)]
175pub struct DevelopmentMaterializeState {
176    instance_identity: Option<String>,
177    unix_millis: Option<U64>,
178    private_plans: BTreeMap<DevelopmentMaterializePrivatePlan, PublishedFileIdentity>,
179    published_private_plans: BTreeMap<DevelopmentMaterializePrivatePlan, PublishedFileIdentity>,
180    staged: BTreeMap<DevelopmentMaterializeArtifact, PublishedFileIdentity>,
181    published: BTreeMap<DevelopmentMaterializeArtifact, PublishedFileIdentity>,
182    published_manifest_sha256: Option<[u8; 32]>,
183    manifest_sha256: Option<[u8; 32]>,
184    completed: bool,
185}
186
187impl DevelopmentMaterializeState {
188    pub fn replay(transitions: &[DevelopmentMaterializeTransition]) -> Result<Self, WorkflowJournalError> {
189        let mut state = Self::default();
190        for transition in transitions {
191            transition.validate()?;
192            state.apply(transition)?;
193        }
194        Ok(state)
195    }
196
197    fn apply(&mut self, transition: &DevelopmentMaterializeTransition) -> Result<(), WorkflowJournalError> {
198        if self.completed {
199            return Err(WorkflowJournalError::Corrupt);
200        }
201        match transition {
202            DevelopmentMaterializeTransition::InstanceIdentityRecorded { instance_identity }
203                if self.instance_identity.is_none() && self.unix_millis.is_none() =>
204            {
205                self.instance_identity = Some(instance_identity.clone());
206            }
207            DevelopmentMaterializeTransition::ClockRecorded { unix_millis }
208                if self.instance_identity.is_some() && self.unix_millis.is_none() =>
209            {
210                self.unix_millis = Some(unix_millis.clone());
211            }
212            DevelopmentMaterializeTransition::PrivatePlanStaged { plan, file }
213                if self.unix_millis.is_some()
214                    && self.staged.is_empty()
215                    && self.published.is_empty()
216                    && !self.private_plans.contains_key(plan) =>
217            {
218                self.private_plans.insert(*plan, file.clone());
219            }
220            DevelopmentMaterializeTransition::PrivatePlanPublished { plan, file }
221                if !self.published_private_plans.contains_key(plan) && self.private_plans.get(plan) == Some(file) =>
222            {
223                self.published_private_plans.insert(*plan, file.clone());
224            }
225            DevelopmentMaterializeTransition::ArtifactStaged { artifact, file }
226                if self
227                    .published_private_plans
228                    .contains_key(&DevelopmentMaterializePrivatePlan::Pki)
229                    && !self.staged.contains_key(artifact)
230                    && !self.published.contains_key(artifact)
231                    && (!artifact.is_manifest()
232                        || self.published.keys().copied().collect::<BTreeSet<_>>()
233                            == DevelopmentMaterializeArtifact::required_before_manifest()) =>
234            {
235                self.staged.insert(*artifact, file.clone());
236            }
237            DevelopmentMaterializeTransition::ArtifactPublished {
238                artifact,
239                file,
240                public_sha256,
241            } if !self.published.contains_key(artifact) && self.staged.get(artifact) == Some(file) => {
242                self.published.insert(*artifact, file.clone());
243                if artifact.is_manifest() {
244                    self.published_manifest_sha256 = *public_sha256;
245                }
246            }
247            DevelopmentMaterializeTransition::Verified { manifest_sha256 }
248                if self
249                    .published
250                    .contains_key(&DevelopmentMaterializeArtifact::InstanceManifest)
251                    && self.published_manifest_sha256.as_ref() == Some(manifest_sha256)
252                    && self.manifest_sha256.is_none() =>
253            {
254                self.manifest_sha256 = Some(*manifest_sha256);
255            }
256            DevelopmentMaterializeTransition::Completed if self.manifest_sha256.is_some() => {
257                self.completed = true;
258            }
259            _ => return Err(WorkflowJournalError::Corrupt),
260        }
261        Ok(())
262    }
263
264    #[must_use]
265    pub fn instance_identity(&self) -> Option<&str> {
266        self.instance_identity.as_deref()
267    }
268
269    #[must_use]
270    pub const fn clock(&self) -> Option<&U64> {
271        self.unix_millis.as_ref()
272    }
273
274    #[must_use]
275    pub fn staged(&self, artifact: DevelopmentMaterializeArtifact) -> Option<&PublishedFileIdentity> {
276        self.staged.get(&artifact)
277    }
278
279    #[must_use]
280    pub fn private_plan(&self, plan: DevelopmentMaterializePrivatePlan) -> Option<&PublishedFileIdentity> {
281        self.private_plans.get(&plan)
282    }
283
284    #[must_use]
285    pub fn published_private_plan(&self, plan: DevelopmentMaterializePrivatePlan) -> Option<&PublishedFileIdentity> {
286        self.published_private_plans.get(&plan)
287    }
288
289    #[must_use]
290    pub fn published(&self, artifact: DevelopmentMaterializeArtifact) -> Option<&PublishedFileIdentity> {
291        self.published.get(&artifact)
292    }
293
294    #[must_use]
295    pub const fn completed(&self) -> bool {
296        self.completed
297    }
298}
299
300#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
301#[serde(rename_all = "snake_case")]
302pub enum DevelopmentPkiSuccessorOperation {
303    RenewLeaves,
304    RotateCa,
305}
306
307#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
308#[serde(deny_unknown_fields)]
309pub struct DevelopmentPkiSuccessorIntent {
310    pub format_version: u16,
311    pub operation: DevelopmentPkiSuccessorOperation,
312    pub instance_identity: String,
313    pub parent_manifest_path: String,
314    pub parent_manifest_sha256: [u8; 32],
315    pub successor_root: String,
316    pub parent_instance_generation: U64,
317    pub successor_instance_generation: U64,
318    pub parent_ca_generation: U64,
319    pub successor_ca_generation: U64,
320    pub parent_process_leaf_generation: U64,
321    pub successor_process_leaf_generation: U64,
322    pub parent_gateway_leaf_generation: U64,
323    pub successor_gateway_leaf_generation: U64,
324}
325
326impl crate::workflow_journal::sealed::Sealed for DevelopmentPkiSuccessorIntent {}
327
328impl WorkflowJournalIntent for DevelopmentPkiSuccessorIntent {
329    type Transition = DevelopmentPkiSuccessorTransition;
330
331    const KIND: LocalOperatorWorkflowKind = LocalOperatorWorkflowKind::DevelopmentPkiSuccessor;
332
333    fn validate(&self) -> Result<(), WorkflowJournalError> {
334        let instance_generations =
335            ordered_successor(&self.parent_instance_generation, &self.successor_instance_generation);
336        let process_generations = ordered_successor(
337            &self.parent_process_leaf_generation,
338            &self.successor_process_leaf_generation,
339        );
340        let gateway_generations = ordered_successor(
341            &self.parent_gateway_leaf_generation,
342            &self.successor_gateway_leaf_generation,
343        );
344        let ca_generations = match self.operation {
345            DevelopmentPkiSuccessorOperation::RenewLeaves => {
346                canonical_u64(&self.parent_ca_generation).filter(|value| *value > 0)
347                    == canonical_u64(&self.successor_ca_generation).filter(|value| *value > 0)
348            }
349            DevelopmentPkiSuccessorOperation::RotateCa => {
350                ordered_successor(&self.parent_ca_generation, &self.successor_ca_generation)
351            }
352        };
353        let parent_manifest = Path::new(&self.parent_manifest_path);
354        let parent_root = parent_manifest.parent();
355        if self.format_version != DEVELOPMENT_PKI_SUCCESSOR_FORMAT_VERSION
356            || !valid_instance_identity(&self.instance_identity)
357            || !valid_absolute_path(&self.parent_manifest_path)
358            || !valid_sha256(&self.parent_manifest_sha256)
359            || !valid_absolute_path(&self.successor_root)
360            || parent_manifest.file_name().and_then(|name| name.to_str()) != Some("instance.json")
361            || parent_root.is_none_or(|root| paths_overlap(root, Path::new(&self.successor_root)))
362            || !instance_generations
363            || !process_generations
364            || !gateway_generations
365            || !ca_generations
366        {
367            return Err(WorkflowJournalError::InvalidIntent);
368        }
369        Ok(())
370    }
371}
372
373fn paths_overlap(left: &Path, right: &Path) -> bool {
374    left.starts_with(right) || right.starts_with(left)
375}
376
377#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
378#[serde(rename_all = "snake_case")]
379pub enum DevelopmentPkiSuccessorArtifact {
380    CaCertificate,
381    CaPrivateKey,
382    ProcessCertificate,
383    ProcessPrivateKey,
384    GatewayCertificate,
385    GatewayPrivateKey,
386    ServerConfiguration,
387    InstanceManifest,
388}
389
390#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
391#[serde(rename_all = "snake_case")]
392pub enum DevelopmentPkiSuccessorPrivatePlan {
393    Pki,
394}
395
396#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
397#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
398pub enum DevelopmentPkiSuccessorTransition {
399    ClockRecorded {
400        unix_millis: U64,
401    },
402    PrivatePlanStaged {
403        plan: DevelopmentPkiSuccessorPrivatePlan,
404        file: PublishedFileIdentity,
405    },
406    PrivatePlanPublished {
407        plan: DevelopmentPkiSuccessorPrivatePlan,
408        file: PublishedFileIdentity,
409    },
410    ArtifactStaged {
411        artifact: DevelopmentPkiSuccessorArtifact,
412        file: PublishedFileIdentity,
413    },
414    ArtifactPublished {
415        artifact: DevelopmentPkiSuccessorArtifact,
416        file: PublishedFileIdentity,
417        #[serde(default, skip_serializing_if = "Option::is_none")]
418        public_sha256: Option<[u8; 32]>,
419    },
420    Verified {
421        manifest_sha256: [u8; 32],
422    },
423    Completed,
424}
425
426impl crate::workflow_journal::sealed::Sealed for DevelopmentPkiSuccessorTransition {}
427
428impl WorkflowJournalTransition for DevelopmentPkiSuccessorTransition {
429    fn validate(&self) -> Result<(), WorkflowJournalError> {
430        let valid = match self {
431            Self::ClockRecorded { unix_millis } => valid_u64(unix_millis),
432            Self::PrivatePlanStaged { file, .. }
433            | Self::PrivatePlanPublished { file, .. }
434            | Self::ArtifactStaged { file, .. } => file.is_valid(),
435            Self::ArtifactPublished {
436                artifact,
437                file,
438                public_sha256,
439            } => {
440                file.is_valid()
441                    && public_sha256.as_ref().is_none_or(valid_sha256)
442                    && (*artifact != DevelopmentPkiSuccessorArtifact::InstanceManifest
443                        || public_sha256.as_ref().is_some_and(valid_sha256))
444            }
445            Self::Verified { manifest_sha256 } => valid_sha256(manifest_sha256),
446            Self::Completed => true,
447        };
448        if valid {
449            Ok(())
450        } else {
451            Err(WorkflowJournalError::InvalidTransition)
452        }
453    }
454}
455
456#[derive(Clone, Debug, Default, PartialEq)]
457pub struct DevelopmentPkiSuccessorState {
458    clock: Option<U64>,
459    private_plans: BTreeMap<DevelopmentPkiSuccessorPrivatePlan, PublishedFileIdentity>,
460    published_private_plans: BTreeMap<DevelopmentPkiSuccessorPrivatePlan, PublishedFileIdentity>,
461    staged: BTreeMap<DevelopmentPkiSuccessorArtifact, PublishedFileIdentity>,
462    published: BTreeMap<DevelopmentPkiSuccessorArtifact, PublishedFileIdentity>,
463    published_manifest_sha256: Option<[u8; 32]>,
464    manifest_sha256: Option<[u8; 32]>,
465    completed: bool,
466}
467
468impl DevelopmentPkiSuccessorState {
469    pub fn replay(
470        intent: &DevelopmentPkiSuccessorIntent,
471        transitions: &[DevelopmentPkiSuccessorTransition],
472    ) -> Result<Self, WorkflowJournalError> {
473        intent.validate()?;
474        let mut state = Self::default();
475        for transition in transitions {
476            transition.validate()?;
477            state.apply(intent.operation, transition)?;
478        }
479        Ok(state)
480    }
481
482    fn apply(
483        &mut self,
484        operation: DevelopmentPkiSuccessorOperation,
485        transition: &DevelopmentPkiSuccessorTransition,
486    ) -> Result<(), WorkflowJournalError> {
487        if self.completed {
488            return Err(WorkflowJournalError::Corrupt);
489        }
490        match transition {
491            DevelopmentPkiSuccessorTransition::ClockRecorded { unix_millis } if self.clock.is_none() => {
492                self.clock = Some(unix_millis.clone());
493            }
494            DevelopmentPkiSuccessorTransition::PrivatePlanStaged { plan, file }
495                if self.clock.is_some()
496                    && self.staged.is_empty()
497                    && self.published.is_empty()
498                    && !self.private_plans.contains_key(plan) =>
499            {
500                self.private_plans.insert(*plan, file.clone());
501            }
502            DevelopmentPkiSuccessorTransition::PrivatePlanPublished { plan, file }
503                if !self.published_private_plans.contains_key(plan) && self.private_plans.get(plan) == Some(file) =>
504            {
505                self.published_private_plans.insert(*plan, file.clone());
506            }
507            DevelopmentPkiSuccessorTransition::ArtifactStaged { artifact, file }
508                if self.clock.is_some()
509                    && self
510                        .published_private_plans
511                        .contains_key(&DevelopmentPkiSuccessorPrivatePlan::Pki)
512                    && !self.staged.contains_key(artifact)
513                    && !self.published.contains_key(artifact)
514                    && (artifact_admitted_for_pki_operation(operation, *artifact)
515                        || (*artifact == DevelopmentPkiSuccessorArtifact::InstanceManifest
516                            && self.published.keys().copied().collect::<BTreeSet<_>>()
517                                == required_pki_artifacts(operation))) =>
518            {
519                self.staged.insert(*artifact, file.clone());
520            }
521            DevelopmentPkiSuccessorTransition::ArtifactPublished {
522                artifact,
523                file,
524                public_sha256,
525            } if !self.published.contains_key(artifact) && self.staged.get(artifact) == Some(file) => {
526                self.published.insert(*artifact, file.clone());
527                if *artifact == DevelopmentPkiSuccessorArtifact::InstanceManifest {
528                    self.published_manifest_sha256 = *public_sha256;
529                }
530            }
531            DevelopmentPkiSuccessorTransition::Verified { manifest_sha256 }
532                if self
533                    .published
534                    .contains_key(&DevelopmentPkiSuccessorArtifact::InstanceManifest)
535                    && self.published_manifest_sha256.as_ref() == Some(manifest_sha256)
536                    && self.manifest_sha256.is_none() =>
537            {
538                self.manifest_sha256 = Some(*manifest_sha256);
539            }
540            DevelopmentPkiSuccessorTransition::Completed if self.manifest_sha256.is_some() => {
541                self.completed = true;
542            }
543            _ => return Err(WorkflowJournalError::Corrupt),
544        }
545        Ok(())
546    }
547
548    #[must_use]
549    pub const fn clock(&self) -> Option<&U64> {
550        self.clock.as_ref()
551    }
552
553    #[must_use]
554    pub fn private_plan(&self, plan: DevelopmentPkiSuccessorPrivatePlan) -> Option<&PublishedFileIdentity> {
555        self.private_plans.get(&plan)
556    }
557
558    #[must_use]
559    pub fn published_private_plan(&self, plan: DevelopmentPkiSuccessorPrivatePlan) -> Option<&PublishedFileIdentity> {
560        self.published_private_plans.get(&plan)
561    }
562
563    #[must_use]
564    pub fn staged(&self, artifact: DevelopmentPkiSuccessorArtifact) -> Option<&PublishedFileIdentity> {
565        self.staged.get(&artifact)
566    }
567
568    #[must_use]
569    pub fn published(&self, artifact: DevelopmentPkiSuccessorArtifact) -> Option<&PublishedFileIdentity> {
570        self.published.get(&artifact)
571    }
572
573    #[must_use]
574    pub const fn completed(&self) -> bool {
575        self.completed
576    }
577}
578
579fn artifact_admitted_for_pki_operation(
580    operation: DevelopmentPkiSuccessorOperation,
581    artifact: DevelopmentPkiSuccessorArtifact,
582) -> bool {
583    artifact != DevelopmentPkiSuccessorArtifact::InstanceManifest
584        && (operation == DevelopmentPkiSuccessorOperation::RotateCa
585            || !matches!(
586                artifact,
587                DevelopmentPkiSuccessorArtifact::CaCertificate | DevelopmentPkiSuccessorArtifact::CaPrivateKey
588            ))
589}
590
591fn required_pki_artifacts(operation: DevelopmentPkiSuccessorOperation) -> BTreeSet<DevelopmentPkiSuccessorArtifact> {
592    let mut required = [
593        DevelopmentPkiSuccessorArtifact::ProcessCertificate,
594        DevelopmentPkiSuccessorArtifact::ProcessPrivateKey,
595        DevelopmentPkiSuccessorArtifact::GatewayCertificate,
596        DevelopmentPkiSuccessorArtifact::GatewayPrivateKey,
597        DevelopmentPkiSuccessorArtifact::ServerConfiguration,
598    ]
599    .into_iter()
600    .collect::<BTreeSet<_>>();
601    if operation == DevelopmentPkiSuccessorOperation::RotateCa {
602        required.insert(DevelopmentPkiSuccessorArtifact::CaCertificate);
603        required.insert(DevelopmentPkiSuccessorArtifact::CaPrivateKey);
604    }
605    required
606}
607
608#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
609#[serde(deny_unknown_fields)]
610pub struct DevelopmentReconcileIntent {
611    pub format_version: u16,
612    pub instance_root: String,
613    pub instance_manifest_sha256: [u8; 32],
614    pub index_definition_path: String,
615    pub index_definition_sha256: [u8; 32],
616    pub credential_output: String,
617    pub profile_output: String,
618    pub material_output: String,
619    pub timeout_millis: U64,
620    pub reservation_ttl_millis: U64,
621    pub credential_ttl_millis: U64,
622}
623
624impl crate::workflow_journal::sealed::Sealed for DevelopmentReconcileIntent {}
625
626impl WorkflowJournalIntent for DevelopmentReconcileIntent {
627    type Transition = DevelopmentReconcileTransition;
628
629    const KIND: LocalOperatorWorkflowKind = LocalOperatorWorkflowKind::DevelopmentReconcile;
630
631    fn validate(&self) -> Result<(), WorkflowJournalError> {
632        let paths = [
633            &self.instance_root,
634            &self.index_definition_path,
635            &self.credential_output,
636            &self.profile_output,
637            &self.material_output,
638        ];
639        let distinct_outputs = [&self.credential_output, &self.profile_output, &self.material_output]
640            .into_iter()
641            .collect::<BTreeSet<_>>()
642            .len()
643            == 3;
644        if self.format_version != DEVELOPMENT_RECONCILE_FORMAT_VERSION
645            || paths.into_iter().any(|path| !valid_absolute_path(path))
646            || !distinct_outputs
647            || !valid_sha256(&self.instance_manifest_sha256)
648            || !valid_sha256(&self.index_definition_sha256)
649            || !valid_positive_bounded_duration(&self.timeout_millis)
650            || !valid_positive_bounded_duration(&self.reservation_ttl_millis)
651            || !valid_positive_bounded_duration(&self.credential_ttl_millis)
652            || !ordered_u64(&self.reservation_ttl_millis, &self.credential_ttl_millis)
653        {
654            return Err(WorkflowJournalError::InvalidIntent);
655        }
656        Ok(())
657    }
658}
659
660#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
661#[serde(rename_all = "snake_case")]
662pub enum DevelopmentReconcileRoleTarget {
663    Administrator,
664    Consumer,
665}
666
667#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
668#[serde(rename_all = "snake_case")]
669pub enum DevelopmentReconcileTerminalOutcome {
670    EffectDeadlineExpired,
671    Conflict,
672    ReservationExpiredNoEffect,
673}
674
675#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
676#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
677pub enum DevelopmentReconcileTransition {
678    EffectDeadlineRecorded {
679        unix_millis: U64,
680    },
681    AuthorityObserved {
682        cluster_id: Identity,
683        collection_id: Identity,
684        schema_sha256: Digest,
685        logical_time: U64,
686        reservation_expires_at: U64,
687        credential_expires_at: U64,
688    },
689    CatalogPrepared {
690        locator: Locator,
691        semantic_digest: Digest,
692        expires_at: U64,
693        resource_spec: IndexResourceSpec,
694    },
695    CatalogApplied {
696        locator: Locator,
697        resource: Resource,
698    },
699    IndexActive {
700        resource: Resource,
701        index_revision: U64,
702    },
703    RolePrepared {
704        target: DevelopmentReconcileRoleTarget,
705        locator: Locator,
706        semantic_digest: Digest,
707        expires_at: U64,
708        predecessor_role_version: U64,
709        successor_role_version: U64,
710    },
711    RoleApplied {
712        target: DevelopmentReconcileRoleTarget,
713        locator: Locator,
714        target_policy_revision: U64,
715    },
716    RoleVerified {
717        target: DevelopmentReconcileRoleTarget,
718        role_version: U64,
719        observed_policy_revision: U64,
720    },
721    CredentialReserved {
722        reservation: ServiceCredentialReservation,
723    },
724    CredentialStaged {
725        file: PublishedFileIdentity,
726    },
727    CredentialActivated {
728        principal_id: Identity,
729        api_key_id: Identity,
730        generation: U64,
731    },
732    CredentialPublished {
733        file: PublishedFileIdentity,
734    },
735    ProfilePublished {
736        file: PublishedFileIdentity,
737        profile_sha256: [u8; 32],
738    },
739    ConsumerVerified {
740        observed_policy_revision: U64,
741    },
742    MaterialPublished {
743        file: PublishedFileIdentity,
744        material_sha256: [u8; 32],
745    },
746    Terminal {
747        outcome: DevelopmentReconcileTerminalOutcome,
748    },
749    Completed,
750}
751
752impl crate::workflow_journal::sealed::Sealed for DevelopmentReconcileTransition {}
753
754impl WorkflowJournalTransition for DevelopmentReconcileTransition {
755    fn validate(&self) -> Result<(), WorkflowJournalError> {
756        let valid = match self {
757            Self::EffectDeadlineRecorded { unix_millis } => valid_u64(unix_millis),
758            Self::AuthorityObserved {
759                cluster_id,
760                collection_id,
761                schema_sha256,
762                logical_time,
763                reservation_expires_at,
764                credential_expires_at,
765            } => {
766                valid_identity(cluster_id)
767                    && valid_identity(collection_id)
768                    && valid_digest(schema_sha256)
769                    && valid_u64(logical_time)
770                    && ordered_u64(logical_time, reservation_expires_at)
771                    && ordered_u64(reservation_expires_at, credential_expires_at)
772            }
773            Self::CatalogPrepared {
774                locator,
775                semantic_digest,
776                expires_at,
777                resource_spec,
778            } => {
779                valid_locator(locator)
780                    && valid_digest(semantic_digest)
781                    && valid_u64(expires_at)
782                    && resource_spec.format_version == u32::from(LODESTONE_API_INDEX_RESOURCE_SPEC_FORMAT_VERSION)
783            }
784            Self::CatalogApplied { locator, resource } => valid_locator(locator) && valid_resource(resource),
785            Self::IndexActive {
786                resource,
787                index_revision,
788            } => valid_resource(resource) && valid_u64(index_revision),
789            Self::RolePrepared {
790                locator,
791                semantic_digest,
792                expires_at,
793                predecessor_role_version,
794                successor_role_version,
795                ..
796            } => {
797                valid_locator(locator)
798                    && valid_digest(semantic_digest)
799                    && valid_u64(expires_at)
800                    && ordered_successor(predecessor_role_version, successor_role_version)
801            }
802            Self::RoleApplied {
803                locator,
804                target_policy_revision,
805                ..
806            } => valid_locator(locator) && valid_u64(target_policy_revision),
807            Self::RoleVerified {
808                role_version,
809                observed_policy_revision,
810                ..
811            } => valid_u64(role_version) && valid_u64(observed_policy_revision),
812            Self::CredentialReserved { reservation } => valid_reservation(reservation),
813            Self::CredentialStaged { file } | Self::CredentialPublished { file } => file.is_valid(),
814            Self::CredentialActivated {
815                principal_id,
816                api_key_id,
817                generation,
818            } => valid_identity(principal_id) && valid_identity(api_key_id) && valid_u64(generation),
819            Self::ProfilePublished { file, profile_sha256 } => file.is_valid() && valid_sha256(profile_sha256),
820            Self::ConsumerVerified {
821                observed_policy_revision,
822            } => valid_u64(observed_policy_revision),
823            Self::MaterialPublished { file, material_sha256 } => file.is_valid() && valid_sha256(material_sha256),
824            Self::Terminal { .. } => true,
825            Self::Completed => true,
826        };
827        if valid {
828            Ok(())
829        } else {
830            Err(WorkflowJournalError::InvalidTransition)
831        }
832    }
833}
834
835#[derive(Clone, Debug, Default, PartialEq)]
836pub struct DevelopmentReconcileState {
837    effect_deadline: Option<U64>,
838    binding: Option<(Identity, Identity, Digest, U64, U64, U64)>,
839    catalog_preparation: Option<(Locator, Digest, U64, IndexResourceSpec)>,
840    resource: Option<Resource>,
841    index_active: bool,
842    role_preparations: BTreeMap<DevelopmentReconcileRoleTarget, (Locator, Digest, U64, U64, U64)>,
843    role_applied: BTreeMap<DevelopmentReconcileRoleTarget, U64>,
844    role_verified: BTreeMap<DevelopmentReconcileRoleTarget, (U64, U64)>,
845    reservation: Option<ServiceCredentialReservation>,
846    credential_staged: Option<PublishedFileIdentity>,
847    credential_activated: bool,
848    credential_published: Option<PublishedFileIdentity>,
849    profile_published: Option<PublishedFileIdentity>,
850    consumer_verified: Option<U64>,
851    material_published: Option<PublishedFileIdentity>,
852    terminal: Option<DevelopmentReconcileTerminalOutcome>,
853    completed: bool,
854}
855
856impl DevelopmentReconcileState {
857    pub fn replay(
858        intent: &DevelopmentReconcileIntent,
859        transitions: &[DevelopmentReconcileTransition],
860    ) -> Result<Self, WorkflowJournalError> {
861        intent.validate()?;
862        let mut state = Self::default();
863        for transition in transitions {
864            transition.validate()?;
865            state.apply(intent, transition)?;
866        }
867        Ok(state)
868    }
869
870    fn apply(
871        &mut self,
872        intent: &DevelopmentReconcileIntent,
873        transition: &DevelopmentReconcileTransition,
874    ) -> Result<(), WorkflowJournalError> {
875        if self.completed || self.terminal.is_some() {
876            return Err(WorkflowJournalError::Corrupt);
877        }
878        match transition {
879            DevelopmentReconcileTransition::EffectDeadlineRecorded { unix_millis }
880                if self.effect_deadline.is_none() =>
881            {
882                self.effect_deadline = Some(unix_millis.clone());
883            }
884            DevelopmentReconcileTransition::AuthorityObserved {
885                cluster_id,
886                collection_id,
887                schema_sha256,
888                logical_time,
889                reservation_expires_at,
890                credential_expires_at,
891            } if self.effect_deadline.is_some()
892                && self.binding.is_none()
893                && checked_add_u64(logical_time, &intent.reservation_ttl_millis)
894                    == canonical_u64(reservation_expires_at)
895                && checked_add_u64(logical_time, &intent.credential_ttl_millis)
896                    == canonical_u64(credential_expires_at) =>
897            {
898                self.binding = Some((
899                    cluster_id.clone(),
900                    collection_id.clone(),
901                    schema_sha256.clone(),
902                    logical_time.clone(),
903                    reservation_expires_at.clone(),
904                    credential_expires_at.clone(),
905                ));
906            }
907            DevelopmentReconcileTransition::CatalogPrepared {
908                locator,
909                semantic_digest,
910                expires_at,
911                resource_spec,
912            } if self.binding.is_some() && self.resource.is_none() => {
913                // A preparation is effect-free. If its exact catalog root is
914                // stale before submission, reconciliation may durably replace
915                // it while preserving the same desired index definition.
916                self.catalog_preparation = Some((
917                    locator.clone(),
918                    semantic_digest.clone(),
919                    expires_at.clone(),
920                    resource_spec.clone(),
921                ));
922            }
923            DevelopmentReconcileTransition::CatalogApplied { locator, resource }
924                if self
925                    .catalog_preparation
926                    .as_ref()
927                    .is_some_and(|(expected, _, _, _)| expected == locator)
928                    && self.resource.is_none() =>
929            {
930                self.resource = Some(resource.clone());
931            }
932            DevelopmentReconcileTransition::IndexActive { resource, .. }
933                if self.binding.is_some()
934                    && self.resource.as_ref().is_none_or(|current| current == resource)
935                    && !self.index_active =>
936            {
937                self.resource = Some(resource.clone());
938                self.index_active = true;
939            }
940            DevelopmentReconcileTransition::RolePrepared {
941                target,
942                locator,
943                semantic_digest,
944                expires_at,
945                predecessor_role_version,
946                successor_role_version,
947            } if self.role_target_is_next(*target) && !self.role_applied.contains_key(target) => {
948                // Security policy can advance between prepare and submit. A
949                // not-submitted stale preparation is safely replaceable.
950                self.role_preparations.insert(
951                    *target,
952                    (
953                        locator.clone(),
954                        semantic_digest.clone(),
955                        expires_at.clone(),
956                        predecessor_role_version.clone(),
957                        successor_role_version.clone(),
958                    ),
959                );
960            }
961            DevelopmentReconcileTransition::RoleApplied {
962                target,
963                locator,
964                target_policy_revision,
965            } if self
966                .role_preparations
967                .get(target)
968                .is_some_and(|(expected, _, _, _, _)| expected == locator)
969                && !self.role_applied.contains_key(target) =>
970            {
971                self.role_applied.insert(*target, target_policy_revision.clone());
972            }
973            DevelopmentReconcileTransition::RoleVerified {
974                target,
975                role_version,
976                observed_policy_revision,
977            } if self.role_target_is_next(*target)
978                && self.role_verification_matches(*target, role_version, observed_policy_revision) =>
979            {
980                self.role_verified
981                    .insert(*target, (role_version.clone(), observed_policy_revision.clone()));
982            }
983            DevelopmentReconcileTransition::CredentialReserved { reservation }
984                if self
985                    .role_verified
986                    .contains_key(&DevelopmentReconcileRoleTarget::Consumer)
987                    && self.reservation.is_none() =>
988            {
989                let Some((_, _, _, _, reservation_expires_at, credential_expires_at)) = self.binding.as_ref() else {
990                    return Err(WorkflowJournalError::Corrupt);
991                };
992                if &reservation.reservation_expires_at != reservation_expires_at
993                    || &reservation.credential_expires_at != credential_expires_at
994                {
995                    return Err(WorkflowJournalError::Corrupt);
996                }
997                self.reservation = Some(reservation.clone());
998            }
999            DevelopmentReconcileTransition::CredentialStaged { file }
1000                if self.reservation.is_some() && self.credential_staged.is_none() =>
1001            {
1002                self.credential_staged = Some(file.clone());
1003            }
1004            DevelopmentReconcileTransition::CredentialActivated {
1005                principal_id,
1006                api_key_id,
1007                generation,
1008            } if self.reservation.as_ref().is_some_and(|reservation| {
1009                &reservation.principal_id == principal_id
1010                    && &reservation.api_key_id == api_key_id
1011                    && &reservation.generation == generation
1012            }) && self.credential_staged.is_some()
1013                && !self.credential_activated =>
1014            {
1015                self.credential_activated = true;
1016            }
1017            DevelopmentReconcileTransition::CredentialPublished { file }
1018                if self.credential_activated
1019                    && self.credential_staged.as_ref() == Some(file)
1020                    && self.credential_published.is_none() =>
1021            {
1022                self.credential_published = Some(file.clone());
1023            }
1024            DevelopmentReconcileTransition::ProfilePublished { file, .. }
1025                if self.credential_published.is_some() && self.profile_published.is_none() =>
1026            {
1027                self.profile_published = Some(file.clone());
1028            }
1029            DevelopmentReconcileTransition::ConsumerVerified {
1030                observed_policy_revision,
1031            } if self.profile_published.is_some() && self.consumer_verified.is_none() => {
1032                self.consumer_verified = Some(observed_policy_revision.clone());
1033            }
1034            DevelopmentReconcileTransition::MaterialPublished { file, .. }
1035                if self.consumer_verified.is_some() && self.material_published.is_none() =>
1036            {
1037                self.material_published = Some(file.clone());
1038            }
1039            DevelopmentReconcileTransition::Terminal { outcome, .. } if self.effect_deadline.is_some() => {
1040                self.terminal = Some(*outcome);
1041            }
1042            DevelopmentReconcileTransition::Completed if self.material_published.is_some() => {
1043                self.completed = true;
1044            }
1045            _ => return Err(WorkflowJournalError::Corrupt),
1046        }
1047        Ok(())
1048    }
1049
1050    fn role_target_is_next(&self, target: DevelopmentReconcileRoleTarget) -> bool {
1051        self.index_active
1052            && match target {
1053                DevelopmentReconcileRoleTarget::Administrator => !self
1054                    .role_verified
1055                    .contains_key(&DevelopmentReconcileRoleTarget::Administrator),
1056                DevelopmentReconcileRoleTarget::Consumer => {
1057                    self.role_verified
1058                        .contains_key(&DevelopmentReconcileRoleTarget::Administrator)
1059                        && !self
1060                            .role_verified
1061                            .contains_key(&DevelopmentReconcileRoleTarget::Consumer)
1062                }
1063            }
1064    }
1065
1066    fn role_verification_matches(
1067        &self,
1068        target: DevelopmentReconcileRoleTarget,
1069        role_version: &U64,
1070        observed_policy_revision: &U64,
1071    ) -> bool {
1072        match self.role_preparations.get(&target) {
1073            Some((_, _, _, _, successor_role_version)) => {
1074                successor_role_version == role_version
1075                    && self.role_applied.get(&target).is_some_and(|target_policy_revision| {
1076                        ordered_or_equal_u64(target_policy_revision, observed_policy_revision)
1077                    })
1078            }
1079            None => !self.role_applied.contains_key(&target),
1080        }
1081    }
1082
1083    #[must_use]
1084    pub const fn effect_deadline(&self) -> Option<&U64> {
1085        self.effect_deadline.as_ref()
1086    }
1087
1088    #[must_use]
1089    pub const fn resource(&self) -> Option<&Resource> {
1090        self.resource.as_ref()
1091    }
1092
1093    #[must_use]
1094    pub const fn authority_binding(&self) -> Option<&(Identity, Identity, Digest, U64, U64, U64)> {
1095        self.binding.as_ref()
1096    }
1097
1098    #[must_use]
1099    pub const fn catalog_preparation(&self) -> Option<&(Locator, Digest, U64, IndexResourceSpec)> {
1100        self.catalog_preparation.as_ref()
1101    }
1102
1103    #[must_use]
1104    pub const fn index_active(&self) -> bool {
1105        self.index_active
1106    }
1107
1108    #[must_use]
1109    pub fn role_preparation(
1110        &self,
1111        target: DevelopmentReconcileRoleTarget,
1112    ) -> Option<&(Locator, Digest, U64, U64, U64)> {
1113        self.role_preparations.get(&target)
1114    }
1115
1116    #[must_use]
1117    pub fn role_applied(&self, target: DevelopmentReconcileRoleTarget) -> Option<&U64> {
1118        self.role_applied.get(&target)
1119    }
1120
1121    #[must_use]
1122    pub fn role_verified(&self, target: DevelopmentReconcileRoleTarget) -> Option<&(U64, U64)> {
1123        self.role_verified.get(&target)
1124    }
1125
1126    #[must_use]
1127    pub const fn reservation(&self) -> Option<&ServiceCredentialReservation> {
1128        self.reservation.as_ref()
1129    }
1130
1131    #[must_use]
1132    pub const fn credential_staged(&self) -> Option<&PublishedFileIdentity> {
1133        self.credential_staged.as_ref()
1134    }
1135
1136    #[must_use]
1137    pub const fn credential_activated(&self) -> bool {
1138        self.credential_activated
1139    }
1140
1141    #[must_use]
1142    pub const fn credential_published(&self) -> Option<&PublishedFileIdentity> {
1143        self.credential_published.as_ref()
1144    }
1145
1146    #[must_use]
1147    pub const fn profile_published(&self) -> Option<&PublishedFileIdentity> {
1148        self.profile_published.as_ref()
1149    }
1150
1151    #[must_use]
1152    pub const fn consumer_policy_revision(&self) -> Option<&U64> {
1153        self.consumer_verified.as_ref()
1154    }
1155
1156    #[must_use]
1157    pub const fn material_published(&self) -> Option<&PublishedFileIdentity> {
1158        self.material_published.as_ref()
1159    }
1160
1161    #[must_use]
1162    pub const fn terminal(&self) -> Option<DevelopmentReconcileTerminalOutcome> {
1163        self.terminal
1164    }
1165
1166    #[must_use]
1167    pub const fn completed(&self) -> bool {
1168        self.completed
1169    }
1170}
1171
1172fn valid_absolute_path(value: &str) -> bool {
1173    if value.is_empty() || value.len() > 4_096 || value.bytes().any(|byte| byte.is_ascii_control()) {
1174        return false;
1175    }
1176    let path = Path::new(value);
1177    path.is_absolute()
1178        && path.parent().is_some()
1179        && path
1180            .components()
1181            .all(|component| matches!(component, Component::RootDir | Component::Normal(_)))
1182        && path.components().collect::<PathBuf>().as_os_str() == path.as_os_str()
1183}
1184
1185fn valid_sha256(value: &[u8; 32]) -> bool {
1186    value.iter().any(|byte| *byte != 0)
1187}
1188
1189fn valid_instance_identity(value: &str) -> bool {
1190    value.len() == 64
1191        && value
1192            .bytes()
1193            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1194        && value.bytes().any(|byte| byte != b'0')
1195}
1196
1197fn valid_git_object(value: &str) -> bool {
1198    matches!(value.len(), 40 | 64)
1199        && value
1200            .bytes()
1201            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1202        && value.bytes().any(|byte| byte != b'0')
1203}
1204
1205fn valid_development_endpoints(native: &str, subscription: &str, internal: &str, bootstrap: &str) -> bool {
1206    let values = [native, subscription, internal, bootstrap];
1207    if values.iter().any(|value| {
1208        value.is_empty() || value.len() > MAX_ENDPOINT_BYTES || !value.bytes().all(|byte| byte.is_ascii_graphic())
1209    }) {
1210        return false;
1211    }
1212    let Some(authority) = native.strip_prefix("lodestone+tls://") else {
1213        return false;
1214    };
1215    let (advertised_host, port_text) = if let Some(port) = authority.strip_prefix("[::1]:") {
1216        ("[::1]", port)
1217    } else if let Some(port) = authority.strip_prefix("localhost:") {
1218        ("localhost", port)
1219    } else if let Some(port) = authority.strip_prefix("127.0.0.1:") {
1220        ("127.0.0.1", port)
1221    } else {
1222        return false;
1223    };
1224    let Some(base_port) = port_text
1225        .parse::<u16>()
1226        .ok()
1227        .filter(|port| port.to_string() == port_text && (1024..=65532).contains(port))
1228    else {
1229        return false;
1230    };
1231    let bind_host = match advertised_host {
1232        "[::1]" => "[::1]",
1233        _ => "127.0.0.1",
1234    };
1235    subscription == format!("https://{advertised_host}:{}", base_port + 1)
1236        && internal == format!("{bind_host}:{}", base_port + 2)
1237        && bootstrap == format!("{bind_host}:{}", base_port + 3)
1238}
1239
1240fn valid_identity(value: &Identity) -> bool {
1241    value.value.len() == 32
1242        && value
1243            .value
1244            .bytes()
1245            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1246        && value.value.bytes().any(|byte| byte != b'0')
1247}
1248
1249fn valid_u64(value: &U64) -> bool {
1250    value
1251        .value
1252        .parse::<u64>()
1253        .ok()
1254        .is_some_and(|parsed| parsed.to_string() == value.value)
1255}
1256
1257fn canonical_u64(value: &U64) -> Option<u64> {
1258    value
1259        .value
1260        .parse::<u64>()
1261        .ok()
1262        .filter(|parsed| parsed.to_string() == value.value)
1263}
1264
1265fn ordered_successor(predecessor: &U64, successor: &U64) -> bool {
1266    canonical_u64(predecessor)
1267        .filter(|value| *value > 0)
1268        .and_then(|value| value.checked_add(1))
1269        .is_some_and(|expected| canonical_u64(successor) == Some(expected))
1270}
1271
1272fn ordered_u64(predecessor: &U64, successor: &U64) -> bool {
1273    canonical_u64(predecessor)
1274        .zip(canonical_u64(successor))
1275        .is_some_and(|(predecessor, successor)| predecessor < successor)
1276}
1277
1278fn ordered_or_equal_u64(predecessor: &U64, successor: &U64) -> bool {
1279    canonical_u64(predecessor)
1280        .zip(canonical_u64(successor))
1281        .is_some_and(|(predecessor, successor)| predecessor <= successor)
1282}
1283
1284fn checked_add_u64(left: &U64, right: &U64) -> Option<u64> {
1285    canonical_u64(left)?.checked_add(canonical_u64(right)?)
1286}
1287
1288fn valid_positive_bounded_duration(value: &U64) -> bool {
1289    canonical_u64(value).is_some_and(|value| (1..=86_400_000).contains(&value))
1290}
1291
1292fn valid_digest(value: &Digest) -> bool {
1293    value.value.len() == 64
1294        && value
1295            .value
1296            .bytes()
1297            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1298        && value.value.bytes().any(|byte| byte != b'0')
1299}
1300
1301fn valid_locator(value: &Locator) -> bool {
1302    !value.authority.is_empty()
1303        && value.authority.len() <= MAX_NAME_BYTES
1304        && value.authority.bytes().all(|byte| byte.is_ascii_graphic())
1305        && !value.value.is_empty()
1306        && value.value.len() <= 4_096
1307        && value.value.bytes().all(|byte| byte.is_ascii_graphic())
1308}
1309
1310fn valid_resource(value: &Resource) -> bool {
1311    value.kind == "index" && valid_index_name(&value.name) && valid_identity(&value.incarnation)
1312}
1313
1314fn valid_index_name(value: &str) -> bool {
1315    !value.is_empty()
1316        && value.len() <= MAX_NAME_BYTES
1317        && value
1318            .bytes()
1319            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_'))
1320}
1321
1322fn valid_reservation(value: &ServiceCredentialReservation) -> bool {
1323    valid_locator(&value.locator)
1324        && value.locator.authority == "service_credential_reservation"
1325        && valid_digest(&value.reservation_action_id)
1326        && valid_digest(&value.activation_action_id)
1327        && valid_identity(&value.principal_id)
1328        && valid_identity(&value.api_key_id)
1329        && valid_u64(&value.generation)
1330        && valid_u64(&value.reservation_expires_at)
1331        && valid_u64(&value.credential_expires_at)
1332        && value.verifier_algorithm == "sha256_identity_bound_v1"
1333        && value.verifier_algorithm_version == 1
1334        && value.required_secret_bytes == 32
1335}
1336
1337#[cfg(test)]
1338#[allow(clippy::expect_used)] // Fixed nonzero byte fixtures are valid by construction.
1339mod tests {
1340    use super::*;
1341
1342    fn identity(value: u128) -> Identity {
1343        Identity {
1344            value: format!("{value:032x}"),
1345        }
1346    }
1347
1348    fn instance_identity(value: u8) -> String {
1349        format!("{value:02x}").repeat(32)
1350    }
1351
1352    fn digest(value: u8) -> Digest {
1353        Digest {
1354            value: format!("{value:02x}").repeat(32),
1355        }
1356    }
1357
1358    fn u64_value(value: u64) -> U64 {
1359        U64 {
1360            value: value.to_string(),
1361        }
1362    }
1363
1364    fn file(value: u64) -> PublishedFileIdentity {
1365        PublishedFileIdentity {
1366            device: 1,
1367            inode: value,
1368            size: 1,
1369            sha256: [u8::try_from(value).unwrap_or(1); 32],
1370        }
1371    }
1372
1373    fn reconcile_intent() -> DevelopmentReconcileIntent {
1374        DevelopmentReconcileIntent {
1375            format_version: DEVELOPMENT_RECONCILE_FORMAT_VERSION,
1376            instance_root: "/tmp/lodestone".to_owned(),
1377            instance_manifest_sha256: [1; 32],
1378            index_definition_path: "/tmp/lodestone/operator/development-index.json".to_owned(),
1379            index_definition_sha256: [2; 32],
1380            credential_output: "/tmp/output/credential.json".to_owned(),
1381            profile_output: "/tmp/output/profile.json".to_owned(),
1382            material_output: "/tmp/output/material.json".to_owned(),
1383            timeout_millis: u64_value(90),
1384            reservation_ttl_millis: u64_value(10),
1385            credential_ttl_millis: u64_value(20),
1386        }
1387    }
1388
1389    #[test]
1390    fn materialization_manifest_is_last_and_completion_requires_verification() -> Result<(), WorkflowJournalError> {
1391        let mut transitions = vec![
1392            DevelopmentMaterializeTransition::InstanceIdentityRecorded {
1393                instance_identity: instance_identity(1),
1394            },
1395            DevelopmentMaterializeTransition::ClockRecorded {
1396                unix_millis: u64_value(1),
1397            },
1398            DevelopmentMaterializeTransition::PrivatePlanStaged {
1399                plan: DevelopmentMaterializePrivatePlan::Pki,
1400                file: file(200),
1401            },
1402            DevelopmentMaterializeTransition::PrivatePlanPublished {
1403                plan: DevelopmentMaterializePrivatePlan::Pki,
1404                file: file(200),
1405            },
1406        ];
1407        for (position, artifact) in DevelopmentMaterializeArtifact::required_before_manifest()
1408            .into_iter()
1409            .enumerate()
1410        {
1411            let file = file(u64::try_from(position).unwrap_or(0) + 1);
1412            transitions.push(DevelopmentMaterializeTransition::ArtifactStaged {
1413                artifact,
1414                file: file.clone(),
1415            });
1416            transitions.push(DevelopmentMaterializeTransition::ArtifactPublished {
1417                artifact,
1418                file,
1419                public_sha256: None,
1420            });
1421        }
1422        let manifest = file(100);
1423        transitions.push(DevelopmentMaterializeTransition::ArtifactStaged {
1424            artifact: DevelopmentMaterializeArtifact::InstanceManifest,
1425            file: manifest.clone(),
1426        });
1427        transitions.push(DevelopmentMaterializeTransition::ArtifactPublished {
1428            artifact: DevelopmentMaterializeArtifact::InstanceManifest,
1429            file: manifest,
1430            public_sha256: Some([3; 32]),
1431        });
1432        assert!(DevelopmentMaterializeState::replay(&transitions).is_ok());
1433        transitions.push(DevelopmentMaterializeTransition::Verified {
1434            manifest_sha256: [3; 32],
1435        });
1436        transitions.push(DevelopmentMaterializeTransition::Completed);
1437        assert!(DevelopmentMaterializeState::replay(&transitions)?.completed());
1438
1439        let out_of_order = [
1440            DevelopmentMaterializeTransition::InstanceIdentityRecorded {
1441                instance_identity: instance_identity(1),
1442            },
1443            DevelopmentMaterializeTransition::ClockRecorded {
1444                unix_millis: u64_value(1),
1445            },
1446            DevelopmentMaterializeTransition::ArtifactPublished {
1447                artifact: DevelopmentMaterializeArtifact::InstanceManifest,
1448                file: file(1),
1449                public_sha256: Some([3; 32]),
1450            },
1451        ];
1452        assert_eq!(
1453            DevelopmentMaterializeState::replay(&out_of_order),
1454            Err(WorkflowJournalError::Corrupt)
1455        );
1456        Ok(())
1457    }
1458
1459    #[test]
1460    fn pki_successor_requires_ca_artifacts_only_for_ca_rotation() -> Result<(), WorkflowJournalError> {
1461        let intent = DevelopmentPkiSuccessorIntent {
1462            format_version: DEVELOPMENT_PKI_SUCCESSOR_FORMAT_VERSION,
1463            operation: DevelopmentPkiSuccessorOperation::RenewLeaves,
1464            instance_identity: instance_identity(1),
1465            parent_manifest_path: "/tmp/parent/instance.json".to_owned(),
1466            parent_manifest_sha256: [1; 32],
1467            successor_root: "/tmp/successor".to_owned(),
1468            parent_instance_generation: u64_value(1),
1469            successor_instance_generation: u64_value(2),
1470            parent_ca_generation: u64_value(1),
1471            successor_ca_generation: u64_value(1),
1472            parent_process_leaf_generation: u64_value(1),
1473            successor_process_leaf_generation: u64_value(2),
1474            parent_gateway_leaf_generation: u64_value(1),
1475            successor_gateway_leaf_generation: u64_value(2),
1476        };
1477        intent.validate()?;
1478        let illegal = [DevelopmentPkiSuccessorTransition::ArtifactStaged {
1479            artifact: DevelopmentPkiSuccessorArtifact::CaPrivateKey,
1480            file: file(1),
1481        }];
1482        assert_eq!(
1483            DevelopmentPkiSuccessorState::replay(&intent, &illegal),
1484            Err(WorkflowJournalError::Corrupt)
1485        );
1486
1487        let mut transitions = vec![
1488            DevelopmentPkiSuccessorTransition::ClockRecorded {
1489                unix_millis: u64_value(1),
1490            },
1491            DevelopmentPkiSuccessorTransition::PrivatePlanStaged {
1492                plan: DevelopmentPkiSuccessorPrivatePlan::Pki,
1493                file: file(90),
1494            },
1495            DevelopmentPkiSuccessorTransition::PrivatePlanPublished {
1496                plan: DevelopmentPkiSuccessorPrivatePlan::Pki,
1497                file: file(90),
1498            },
1499        ];
1500        for (position, artifact) in required_pki_artifacts(intent.operation).into_iter().enumerate() {
1501            let identity = file(u64::try_from(position).unwrap_or(0) + 1);
1502            transitions.push(DevelopmentPkiSuccessorTransition::ArtifactStaged {
1503                artifact,
1504                file: identity.clone(),
1505            });
1506            transitions.push(DevelopmentPkiSuccessorTransition::ArtifactPublished {
1507                artifact,
1508                file: identity,
1509                public_sha256: None,
1510            });
1511        }
1512        let manifest = file(100);
1513        transitions.push(DevelopmentPkiSuccessorTransition::ArtifactStaged {
1514            artifact: DevelopmentPkiSuccessorArtifact::InstanceManifest,
1515            file: manifest.clone(),
1516        });
1517        transitions.push(DevelopmentPkiSuccessorTransition::ArtifactPublished {
1518            artifact: DevelopmentPkiSuccessorArtifact::InstanceManifest,
1519            file: manifest,
1520            public_sha256: Some([9; 32]),
1521        });
1522        transitions.push(DevelopmentPkiSuccessorTransition::Verified {
1523            manifest_sha256: [9; 32],
1524        });
1525        transitions.push(DevelopmentPkiSuccessorTransition::Completed);
1526        assert!(DevelopmentPkiSuccessorState::replay(&intent, &transitions)?.completed());
1527        Ok(())
1528    }
1529
1530    #[test]
1531    fn reconciliation_enforces_administrator_scope_before_consumer_scope() -> Result<(), WorkflowJournalError> {
1532        let resource = Resource {
1533            kind: "index".to_owned(),
1534            name: "events".to_owned(),
1535            incarnation: identity(4),
1536        };
1537        let prefix = [
1538            DevelopmentReconcileTransition::EffectDeadlineRecorded {
1539                unix_millis: u64_value(100),
1540            },
1541            DevelopmentReconcileTransition::AuthorityObserved {
1542                cluster_id: identity(1),
1543                collection_id: identity(2),
1544                schema_sha256: digest(3),
1545                logical_time: u64_value(10),
1546                reservation_expires_at: u64_value(20),
1547                credential_expires_at: u64_value(30),
1548            },
1549            DevelopmentReconcileTransition::IndexActive {
1550                resource,
1551                index_revision: u64_value(1),
1552            },
1553            DevelopmentReconcileTransition::RoleVerified {
1554                target: DevelopmentReconcileRoleTarget::Consumer,
1555                role_version: u64_value(1),
1556                observed_policy_revision: u64_value(1),
1557            },
1558        ];
1559        assert_eq!(
1560            DevelopmentReconcileState::replay(&reconcile_intent(), &prefix),
1561            Err(WorkflowJournalError::Corrupt)
1562        );
1563        Ok(())
1564    }
1565
1566    #[test]
1567    fn reconciliation_terminal_outcome_is_final_but_unresolved_work_remains_resumable() {
1568        let deadline = DevelopmentReconcileTransition::EffectDeadlineRecorded {
1569            unix_millis: u64_value(100),
1570        };
1571        let terminal = DevelopmentReconcileTransition::Terminal {
1572            outcome: DevelopmentReconcileTerminalOutcome::EffectDeadlineExpired,
1573        };
1574        let state = DevelopmentReconcileState::replay(&reconcile_intent(), &[deadline.clone(), terminal]);
1575        assert_eq!(
1576            state.map(|value| value.terminal()),
1577            Ok(Some(DevelopmentReconcileTerminalOutcome::EffectDeadlineExpired))
1578        );
1579        assert_eq!(
1580            DevelopmentReconcileState::replay(
1581                &reconcile_intent(),
1582                &[
1583                    deadline,
1584                    DevelopmentReconcileTransition::Terminal {
1585                        outcome: DevelopmentReconcileTerminalOutcome::Conflict,
1586                    },
1587                    DevelopmentReconcileTransition::Completed,
1588                ],
1589            ),
1590            Err(WorkflowJournalError::Corrupt)
1591        );
1592
1593        // An unresolved remote locator is already retained by its preparation
1594        // transition. No terminal record is appended, so a later invocation can
1595        // spend a fresh observation budget resolving the same locator.
1596        let mut resumable = vec![
1597            DevelopmentReconcileTransition::EffectDeadlineRecorded {
1598                unix_millis: u64_value(100),
1599            },
1600            DevelopmentReconcileTransition::AuthorityObserved {
1601                cluster_id: identity(1),
1602                collection_id: identity(2),
1603                schema_sha256: digest(3),
1604                logical_time: u64_value(10),
1605                reservation_expires_at: u64_value(20),
1606                credential_expires_at: u64_value(30),
1607            },
1608            DevelopmentReconcileTransition::CatalogPrepared {
1609                locator: Locator {
1610                    authority: "catalog".to_owned(),
1611                    value: "create-events".to_owned(),
1612                },
1613                semantic_digest: digest(4),
1614                expires_at: u64_value(40),
1615                resource_spec: IndexResourceSpec {
1616                    format_version: u32::from(LODESTONE_API_INDEX_RESOURCE_SPEC_FORMAT_VERSION),
1617                    revision: u64_value(1),
1618                    initial_shards: 1,
1619                    target_shard_bytes: u64_value(1),
1620                    durability: crate::ResourceDurabilityClass::RegionalObject,
1621                    storage_class: crate::ResourceStorageClass::Standard,
1622                    ram_admission_weight: 1,
1623                    ram_hard_max_bytes: u64_value(1),
1624                    nvme_admission_weight: 1,
1625                    nvme_hard_max_bytes: u64_value(1),
1626                    refresh_objective_millis: 1,
1627                    memtable_hard_max_bytes: u64_value(1),
1628                    seal_parallelism: 1,
1629                    merge_parallelism: 1,
1630                    vector: lodestone_wire::VectorResourcePolicy {
1631                        quality: lodestone_wire::VectorQualityObjective::Balanced,
1632                        indexing: lodestone_wire::VectorIndexingObjective::Balanced,
1633                        residency: None,
1634                        calibration: None,
1635                    },
1636                    lifecycle_binding: None,
1637                    encryption_required: false,
1638                    resolver_capability: None,
1639                },
1640            },
1641        ];
1642        let mut replacement = resumable[2].clone();
1643        if let DevelopmentReconcileTransition::CatalogPrepared {
1644            locator,
1645            semantic_digest,
1646            ..
1647        } = &mut replacement
1648        {
1649            locator.value = "create-events-after-root-movement".to_owned();
1650            *semantic_digest = digest(5);
1651        }
1652        resumable.push(replacement);
1653        let state = DevelopmentReconcileState::replay(&reconcile_intent(), &resumable);
1654        assert!(state.is_ok_and(|value| {
1655            value.terminal().is_none()
1656                && value
1657                    .catalog_preparation()
1658                    .is_some_and(|(locator, semantic_digest, _, _)| {
1659                        locator.value == "create-events-after-root-movement" && semantic_digest == &digest(5)
1660                    })
1661        }));
1662    }
1663}