Skip to main content

oxide_batch_core/
definition.rs

1//! Restart-relevant job-definition identity.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::error::Error;
5use std::fmt;
6
7use serde_json::json;
8use sha2::{Digest, Sha256};
9
10use crate::{ChunkSize, JobName, StateSchemaId, StateSchemaVersion, StepName};
11
12const MAX_TOKEN_BYTES: usize = 128;
13/// The maximum number of nodes one plan may contain.
14///
15/// The bound is a framework capability rather than durable meaning: the
16/// manifest reader enforces the ceiling of the running build, and raising it
17/// in a later release must not change a fingerprint.
18pub const MAX_NODES: usize = 1_024;
19/// The maximum number of transitions one plan may contain.
20///
21/// The bound is a framework capability, on the same terms as [`MAX_NODES`].
22pub const MAX_TRANSITIONS: usize = 4_096;
23pub(crate) const MAX_MANIFEST_BYTES: usize = 64 * 1024;
24/// The canonical manifest format for a one-step tasklet or chunk definition.
25pub const MANIFEST_FORMAT_ONE_STEP: u16 = 1;
26/// The canonical manifest format that captures a compiled flow graph.
27pub const MANIFEST_FORMAT_FLOW: u16 = 2;
28/// The canonical manifest format for bounded M4 local-scale plans.
29pub const MANIFEST_FORMAT_LOCAL_SCALE: u16 = 3;
30/// The newest canonical manifest format this runtime can interpret.
31pub(crate) const SUPPORTED_MANIFEST_FORMAT: u16 = MANIFEST_FORMAT_LOCAL_SCALE;
32const LEGACY_REVISION: &str = "__m1_repository_port_v1";
33const LEGACY_MANIFEST: &[u8] =
34    br#"{"format":1,"repository_port":"m1","revision":"__m1_repository_port_v1"}"#;
35
36/// Validates one application-owned definition token.
37///
38/// # Errors
39///
40/// Rejects empty values, values longer than 128 UTF-8 bytes, surrounding
41/// whitespace, and control characters.
42pub fn validate_token(value: &str, kind: DefinitionTokenKind) -> Result<(), DefinitionError> {
43    if value.is_empty() {
44        return Err(DefinitionError::EmptyToken { kind });
45    }
46    if value.len() > MAX_TOKEN_BYTES {
47        return Err(DefinitionError::TokenTooLong {
48            kind,
49            max_bytes: MAX_TOKEN_BYTES,
50        });
51    }
52    if value.trim() != value {
53        return Err(DefinitionError::SurroundingWhitespace { kind });
54    }
55    if value.chars().any(char::is_control) {
56        return Err(DefinitionError::ControlCharacter { kind });
57    }
58    Ok(())
59}
60
61/// Declares one validated, application-owned definition token type.
62///
63/// The generated constructor calls [`validate_token`] and returns
64/// [`DefinitionError`], both of which must be in scope where the macro is
65/// invoked.
66#[macro_export]
67macro_rules! definition_token {
68    ($name:ident, $kind:expr, $docs:literal) => {
69        #[doc = $docs]
70        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
71        pub struct $name(String);
72
73        impl $name {
74            /// Validates and constructs the token.
75            ///
76            /// # Errors
77            ///
78            /// Rejects empty values, values longer than 128 UTF-8 bytes,
79            /// surrounding whitespace, and control characters.
80            pub fn new(value: impl Into<String>) -> Result<Self, DefinitionError> {
81                let value = value.into();
82                validate_token(&value, $kind)?;
83                Ok(Self(value))
84            }
85
86            /// Borrows the validated token.
87            #[must_use]
88            pub fn as_str(&self) -> &str {
89                &self.0
90            }
91        }
92    };
93}
94
95definition_token!(
96    DefinitionRevision,
97    DefinitionTokenKind::Revision,
98    "An application-owned audit label for one restart-relevant definition."
99);
100definition_token!(
101    DefinitionUpgradeKey,
102    DefinitionTokenKind::Upgrade,
103    "An application-owned key for one directed definition compatibility edge."
104);
105
106/// One source-to-target durable step mapping for a compatible restart.
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct StepDefinitionUpgrade {
109    source: StepName,
110    target: StepName,
111}
112
113impl StepDefinitionUpgrade {
114    /// Constructs one directed step mapping.
115    #[must_use]
116    pub const fn new(source: StepName, target: StepName) -> Self {
117        Self { source, target }
118    }
119
120    /// Borrows the checkpoint-producing step name.
121    #[must_use]
122    pub const fn source(&self) -> &StepName {
123        &self.source
124    }
125
126    /// Borrows the step name in the proposed definition.
127    #[must_use]
128    pub const fn target(&self) -> &StepName {
129        &self.target
130    }
131}
132
133/// One explicit, directed definition compatibility edge.
134#[derive(Clone, Debug, Eq, PartialEq)]
135pub struct DefinitionUpgrade {
136    key: DefinitionUpgradeKey,
137    from: DefinitionIdentity,
138    to: DefinitionIdentity,
139    step_mapping: BTreeMap<StepName, StepName>,
140}
141
142impl DefinitionUpgrade {
143    /// Validates a direct, non-transitive compatibility edge.
144    ///
145    /// The M2 edge preserves checkpoint and context bytes unchanged, so
146    /// applications may use it only when the mapped steps retain the same
147    /// state schemas and semantics.
148    ///
149    /// # Errors
150    ///
151    /// Rejects self-edges, empty mappings, and mappings that reuse a target.
152    pub fn new(
153        key: DefinitionUpgradeKey,
154        from: DefinitionIdentity,
155        to: DefinitionIdentity,
156        steps: impl IntoIterator<Item = StepDefinitionUpgrade>,
157    ) -> Result<Self, DefinitionError> {
158        if from.manifest_digest() == to.manifest_digest() {
159            return Err(DefinitionError::UpgradeSelfEdge);
160        }
161        let mut step_mapping = BTreeMap::new();
162        let mut targets = BTreeSet::new();
163        for step in steps {
164            if step_mapping
165                .insert(step.source().clone(), step.target().clone())
166                .is_some()
167            {
168                return Err(DefinitionError::DuplicateSourceStep);
169            }
170            if !targets.insert(step.target().clone()) {
171                return Err(DefinitionError::DuplicateTargetStep);
172            }
173        }
174        if step_mapping.is_empty() {
175            return Err(DefinitionError::EmptyStepMapping);
176        }
177        Ok(Self {
178            key,
179            from,
180            to,
181            step_mapping,
182        })
183    }
184
185    /// Borrows the application-owned upgrade key.
186    #[must_use]
187    pub const fn key(&self) -> &DefinitionUpgradeKey {
188        &self.key
189    }
190
191    /// Borrows the checkpoint-producing definition.
192    #[must_use]
193    pub const fn from(&self) -> &DefinitionIdentity {
194        &self.from
195    }
196
197    /// Borrows the proposed definition.
198    #[must_use]
199    pub const fn to(&self) -> &DefinitionIdentity {
200        &self.to
201    }
202
203    /// Borrows the durable source-to-target step mapping.
204    ///
205    /// Durable adapters replay the mapping when they resolve a compatible
206    /// restart, so the order is the validated declaration order.
207    #[must_use]
208    pub fn step_mapping(&self) -> &BTreeMap<StepName, StepName> {
209        &self.step_mapping
210    }
211}
212definition_token!(
213    ComponentRevision,
214    DefinitionTokenKind::Component,
215    "An application-owned revision token for one opaque executable component."
216);
217definition_token!(
218    ClassifierRevision,
219    DefinitionTokenKind::Classifier,
220    "An application-owned revision token for one bounded fault classifier."
221);
222definition_token!(
223    ComponentStreamIdentity,
224    DefinitionTokenKind::Component,
225    "A stable, owner-scoped `ItemStream` namespace token.\n\nThis is the durable component-state namespace: it is never derived from a\ndisplay name, a memory address, or runtime object identity, and it\nparticipates in the restart-relevant chunk definition fingerprint alongside\nthe other component revisions."
226);
227
228/// Component revisions for a one-step chunk definition.
229#[derive(Clone, Debug, Eq, PartialEq)]
230pub struct ChunkComponentRevisions {
231    reader: ComponentRevision,
232    processor: ComponentRevision,
233    writer: ComponentRevision,
234    checkpoint: ComponentRevision,
235    restart: ChunkRestartContract,
236    streams: BTreeMap<ComponentStreamIdentity, ComponentRevision>,
237    completion_policy: Option<ComponentRevision>,
238}
239
240impl ChunkComponentRevisions {
241    /// Constructs the four restart-relevant chunk component revisions.
242    ///
243    /// Registers no `ItemStream` namespaces and no completion-policy
244    /// revision; use [`with_stream_revision`](Self::with_stream_revision) and
245    /// [`with_completion_policy_revision`](Self::with_completion_policy_revision)
246    /// to add them.
247    #[must_use]
248    pub const fn new(
249        reader: ComponentRevision,
250        processor: ComponentRevision,
251        writer: ComponentRevision,
252        checkpoint: ComponentRevision,
253        restart: ChunkRestartContract,
254    ) -> Self {
255        Self {
256            reader,
257            processor,
258            writer,
259            checkpoint,
260            restart,
261            streams: BTreeMap::new(),
262            completion_policy: None,
263        }
264    }
265
266    /// Registers one `ItemStream` namespace's restart-relevant revision.
267    ///
268    /// Namespaces are kept in a deterministic (sorted, not insertion) order so
269    /// the manifest and fingerprint never depend on registration order. A
270    /// definition with no registered streams produces a manifest and
271    /// fingerprint byte-for-byte identical to one built before this method
272    /// existed.
273    #[must_use]
274    pub fn with_stream_revision(
275        mut self,
276        identity: ComponentStreamIdentity,
277        revision: ComponentRevision,
278    ) -> Self {
279        self.streams.insert(identity, revision);
280        self
281    }
282
283    /// Iterates registered `ItemStream` namespace revisions in deterministic
284    /// (sorted) order.
285    pub fn stream_revisions(
286        &self,
287    ) -> impl Iterator<Item = (&ComponentStreamIdentity, &ComponentRevision)> {
288        self.streams.iter()
289    }
290
291    /// Records the restart-relevant revision of an installed
292    /// [`crate::CompletionPolicy`](../../oxide_batch/trait.CompletionPolicy.html)
293    /// (an early-completion policy supplementing the chunk's `ChunkSize`
294    /// ceiling).
295    ///
296    /// A definition with no completion policy installed produces a manifest
297    /// and fingerprint byte-for-byte identical to one built before this
298    /// method existed.
299    #[must_use]
300    pub fn with_completion_policy_revision(mut self, revision: ComponentRevision) -> Self {
301        self.completion_policy = Some(revision);
302        self
303    }
304
305    /// Borrows the completion policy's restart-relevant revision, if one was
306    /// registered.
307    #[must_use]
308    pub const fn completion_policy_revision(&self) -> Option<&ComponentRevision> {
309        self.completion_policy.as_ref()
310    }
311
312    /// Returns the delivery mode declared by the restart contract.
313    #[must_use]
314    pub const fn delivery_mode(&self) -> ChunkDeliveryMode {
315        self.restart.delivery_mode
316    }
317
318    /// Returns the shutdown policy of an already-open chunk.
319    #[must_use]
320    pub const fn in_flight_policy(&self) -> InFlightPolicy {
321        self.restart.in_flight_policy
322    }
323
324    /// Borrows the revision of the component that reads items.
325    #[must_use]
326    pub const fn reader(&self) -> &ComponentRevision {
327        &self.reader
328    }
329
330    /// Borrows the revision of the component that processes items.
331    #[must_use]
332    pub const fn processor(&self) -> &ComponentRevision {
333        &self.processor
334    }
335
336    /// Borrows the revision of the component that writes items.
337    #[must_use]
338    pub const fn writer(&self) -> &ComponentRevision {
339        &self.writer
340    }
341
342    /// Borrows the revision of the component that produces checkpoints.
343    #[must_use]
344    pub const fn checkpoint(&self) -> &ComponentRevision {
345        &self.checkpoint
346    }
347
348    /// Borrows the declared checkpoint schema.
349    #[must_use]
350    pub const fn checkpoint_schema(&self) -> &StateSchemaId {
351        &self.restart.checkpoint_schema
352    }
353
354    /// Returns the declared checkpoint schema version.
355    #[must_use]
356    pub const fn checkpoint_schema_version(&self) -> StateSchemaVersion {
357        self.restart.checkpoint_schema_version
358    }
359
360    /// Borrows the declared execution-context schema.
361    #[must_use]
362    pub const fn context_schema(&self) -> &StateSchemaId {
363        &self.restart.context_schema
364    }
365
366    /// Returns the declared execution-context schema version.
367    #[must_use]
368    pub const fn context_schema_version(&self) -> StateSchemaVersion {
369        self.restart.context_schema_version
370    }
371}
372
373/// The accepted shutdown behavior for an already-open chunk.
374#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
375#[non_exhaustive]
376pub enum InFlightPolicy {
377    /// Complete and commit the open chunk, then stop at its boundary.
378    #[default]
379    FinishChunk,
380    /// Roll back the open chunk and preserve the prior checkpoint.
381    RollbackChunk,
382}
383
384/// Declared delivery boundary included in a chunk definition fingerprint.
385#[derive(Clone, Copy, Debug, Eq, PartialEq)]
386#[non_exhaustive]
387pub enum ChunkDeliveryMode {
388    /// Business writes and progress share one `PostgreSQL` transaction.
389    AtomicSameResource,
390    /// The resource may observe a duplicate after restart.
391    AtLeastOnce,
392}
393
394impl ChunkDeliveryMode {
395    /// Returns the stable name this mode is recorded under in a manifest.
396    ///
397    /// The name is durable: it is hashed into a definition fingerprint, so it
398    /// is fixed for the life of the mode rather than a display string.
399    #[must_use]
400    pub const fn manifest_name(self) -> &'static str {
401        match self {
402            Self::AtomicSameResource => "atomic_same_resource",
403            Self::AtLeastOnce => "at_least_once",
404        }
405    }
406}
407
408/// Restart-state schemas and delivery mode for a chunk definition.
409#[derive(Clone, Debug, Eq, PartialEq)]
410pub struct ChunkRestartContract {
411    checkpoint_schema: StateSchemaId,
412    checkpoint_schema_version: StateSchemaVersion,
413    context_schema: StateSchemaId,
414    context_schema_version: StateSchemaVersion,
415    delivery_mode: ChunkDeliveryMode,
416    in_flight_policy: InFlightPolicy,
417}
418
419impl ChunkRestartContract {
420    /// Constructs the restart-relevant state and delivery declaration.
421    #[must_use]
422    pub const fn new(
423        checkpoint_schema: StateSchemaId,
424        checkpoint_schema_version: StateSchemaVersion,
425        context_schema: StateSchemaId,
426        context_schema_version: StateSchemaVersion,
427        delivery_mode: ChunkDeliveryMode,
428    ) -> Self {
429        Self {
430            checkpoint_schema,
431            checkpoint_schema_version,
432            context_schema,
433            context_schema_version,
434            delivery_mode,
435            in_flight_policy: InFlightPolicy::FinishChunk,
436        }
437    }
438
439    /// Selects the restart-relevant open-chunk shutdown policy.
440    #[must_use]
441    pub const fn with_in_flight_policy(mut self, policy: InFlightPolicy) -> Self {
442        self.in_flight_policy = policy;
443        self
444    }
445}
446
447/// Canonical restart-relevant identity persisted with every execution.
448#[derive(Clone, Eq, PartialEq)]
449pub struct DefinitionIdentity {
450    job_name: Option<JobName>,
451    revision: DefinitionRevision,
452    manifest_format: u16,
453    manifest_digest: [u8; 32],
454    canonical_manifest: Box<[u8]>,
455}
456
457impl DefinitionIdentity {
458    /// Builds the identity durable rows written before manifests carried one.
459    ///
460    /// The bytes are frozen: a durable row that predates manifest identity
461    /// must keep resolving to exactly this value.
462    #[must_use]
463    pub fn legacy() -> Self {
464        Self::from_canonical(
465            None,
466            DefinitionRevision(LEGACY_REVISION.to_owned()),
467            LEGACY_MANIFEST.to_vec(),
468            MANIFEST_FORMAT_ONE_STEP,
469        )
470    }
471
472    /// Builds the canonical identity for a one-step tasklet definition.
473    ///
474    /// # Errors
475    ///
476    /// Returns [`DefinitionError::ManifestEncoding`] if the bounded canonical
477    /// manifest cannot be encoded.
478    pub fn tasklet(
479        job_name: &JobName,
480        step_name: &StepName,
481        revision: DefinitionRevision,
482        component_revision: &ComponentRevision,
483    ) -> Result<Self, DefinitionError> {
484        let manifest = json!({
485            "component": {
486                "tasklet": component_revision.as_str()
487            },
488            "delivery_mode": "best_effort",
489            "format": MANIFEST_FORMAT_ONE_STEP,
490            "job": job_name.as_str(),
491            "kind": "tasklet",
492            "restart_state": "none",
493            "step": step_name.as_str(),
494            "transaction_boundary": "tasklet_completion"
495        });
496        Self::encode(job_name.clone(), revision, &manifest)
497    }
498
499    /// Builds the canonical identity for a one-step chunk definition.
500    ///
501    /// # Errors
502    ///
503    /// Returns [`DefinitionError::ManifestEncoding`] if the bounded canonical
504    /// manifest cannot be encoded.
505    pub fn chunk(
506        job_name: &JobName,
507        step_name: &StepName,
508        chunk_size: ChunkSize,
509        revision: DefinitionRevision,
510        components: &ChunkComponentRevisions,
511    ) -> Result<Self, DefinitionError> {
512        let mut manifest = json!({
513            "chunk_size": chunk_size.get(),
514            "components": {
515                "checkpoint": components.checkpoint.as_str(),
516                "processor": components.processor.as_str(),
517                "reader": components.reader.as_str(),
518                "writer": components.writer.as_str()
519            },
520            "context": {
521                "schema": components.restart.context_schema.as_str(),
522                "version": components.restart.context_schema_version.get()
523            },
524            "checkpoint": {
525                "schema": components.restart.checkpoint_schema.as_str(),
526                "version": components.restart.checkpoint_schema_version.get()
527            },
528            "delivery_mode": components.restart.delivery_mode.manifest_name(),
529            "format": MANIFEST_FORMAT_ONE_STEP,
530            "job": job_name.as_str(),
531            "kind": "chunk",
532            "step": step_name.as_str(),
533            "transaction_boundary": "chunk"
534        });
535        if components.restart.in_flight_policy == InFlightPolicy::RollbackChunk
536            && let Some(object) = manifest.as_object_mut()
537        {
538            object.insert(
539                "in_flight_policy".to_owned(),
540                serde_json::Value::String("rollback_chunk".to_owned()),
541            );
542        }
543        if !components.streams.is_empty()
544            && let Some(object) = manifest.as_object_mut()
545        {
546            let streams: Vec<serde_json::Value> = components
547                .streams
548                .iter()
549                .map(|(identity, revision)| {
550                    json!({
551                        "identity": identity.as_str(),
552                        "revision": revision.as_str(),
553                    })
554                })
555                .collect();
556            object.insert("streams".to_owned(), serde_json::Value::Array(streams));
557        }
558        if let Some(policy_revision) = &components.completion_policy
559            && let Some(object) = manifest.as_object_mut()
560        {
561            object.insert(
562                "completion_policy".to_owned(),
563                serde_json::Value::String(policy_revision.as_str().to_owned()),
564            );
565        }
566        Self::encode(job_name.clone(), revision, &manifest)
567    }
568
569    /// Builds the canonical identity for a compiled flow graph.
570    ///
571    /// The bytes must already be the canonical manifest the plan compiler
572    /// normalized and encoded; this constructor validates, bounds, and hashes
573    /// them. Taking bytes rather than a parsed document keeps the serializer
574    /// out of this contract and makes the encoding the caller is responsible
575    /// for explicit rather than implied.
576    ///
577    /// # Errors
578    ///
579    /// Returns [`DefinitionError::ManifestEncoding`] when the bytes exceed the
580    /// bounded encoding, are not a canonical JSON object, or do not declare a
581    /// supported flow manifest format.
582    pub fn from_flow_manifest(
583        job_name: &JobName,
584        revision: DefinitionRevision,
585        canonical: &[u8],
586    ) -> Result<Self, DefinitionError> {
587        if canonical.len() > MAX_MANIFEST_BYTES {
588            return Err(DefinitionError::ManifestTooLarge {
589                max_bytes: MAX_MANIFEST_BYTES,
590            });
591        }
592        let document: serde_json::Value =
593            serde_json::from_slice(canonical).map_err(|_| DefinitionError::ManifestEncoding)?;
594        let reencoded =
595            serde_json::to_vec(&document).map_err(|_| DefinitionError::ManifestEncoding)?;
596        if !document.is_object() || reencoded != canonical {
597            return Err(DefinitionError::ManifestEncoding);
598        }
599        let format = document
600            .get("format")
601            .and_then(serde_json::Value::as_u64)
602            .and_then(|value| u16::try_from(value).ok())
603            .filter(|value| matches!(*value, MANIFEST_FORMAT_FLOW | MANIFEST_FORMAT_LOCAL_SCALE))
604            .ok_or(DefinitionError::ManifestEncoding)?;
605
606        Ok(Self::from_canonical(
607            Some(job_name.clone()),
608            revision,
609            canonical.to_vec(),
610            format,
611        ))
612    }
613
614    /// Encodes a one-step manifest this crate composed itself.
615    fn encode(
616        job_name: JobName,
617        revision: DefinitionRevision,
618        manifest: &serde_json::Value,
619    ) -> Result<Self, DefinitionError> {
620        let canonical =
621            serde_json::to_vec(manifest).map_err(|_| DefinitionError::ManifestEncoding)?;
622        if canonical.len() > MAX_MANIFEST_BYTES {
623            return Err(DefinitionError::ManifestTooLarge {
624                max_bytes: MAX_MANIFEST_BYTES,
625            });
626        }
627        Ok(Self::from_canonical(
628            Some(job_name),
629            revision,
630            canonical,
631            MANIFEST_FORMAT_ONE_STEP,
632        ))
633    }
634
635    fn from_canonical(
636        job_name: Option<JobName>,
637        revision: DefinitionRevision,
638        canonical: Vec<u8>,
639        format: u16,
640    ) -> Self {
641        let digest: [u8; 32] = Sha256::digest(&canonical).into();
642        Self {
643            job_name,
644            revision,
645            manifest_format: format,
646            manifest_digest: digest,
647            canonical_manifest: canonical.into_boxed_slice(),
648        }
649    }
650
651    /// Borrows the application-owned definition revision.
652    #[must_use]
653    pub const fn revision(&self) -> &DefinitionRevision {
654        &self.revision
655    }
656
657    /// Borrows the job name bound into a framework-produced manifest.
658    ///
659    /// Legacy direct repository calls use an internal compatibility manifest
660    /// without a bound name.
661    #[must_use]
662    pub const fn job_name(&self) -> Option<&JobName> {
663        self.job_name.as_ref()
664    }
665
666    /// Returns the canonical manifest format version.
667    #[must_use]
668    pub const fn manifest_format(&self) -> u16 {
669        self.manifest_format
670    }
671
672    /// Returns the framework-produced SHA-256 manifest digest.
673    #[must_use]
674    pub const fn manifest_digest(&self) -> &[u8; 32] {
675        &self.manifest_digest
676    }
677
678    /// Borrows the exact canonical manifest bytes that produced the digest.
679    ///
680    /// The manifest records names, logical identifiers, revisions, schema
681    /// versions, and bounded policy values. Parameters, contexts, item values,
682    /// credentials, endpoints, and component-private state are never encoded
683    /// into it, so operators may inspect and archive these bytes.
684    #[must_use]
685    pub fn canonical_manifest(&self) -> &[u8] {
686        &self.canonical_manifest
687    }
688}
689
690/// Returns whether this runtime can interpret `format`.
691///
692/// # Errors
693///
694/// Returns [`ManifestError::UnsupportedFormat`] for a newer format and
695/// [`ManifestError::MissingFormat`] for zero.
696pub const fn check_manifest_format(format: u16) -> Result<(), ManifestError> {
697    if format == 0 {
698        return Err(ManifestError::MissingFormat);
699    }
700    if format > SUPPORTED_MANIFEST_FORMAT {
701        return Err(ManifestError::UnsupportedFormat {
702            format,
703            supported: SUPPORTED_MANIFEST_FORMAT,
704        });
705    }
706    Ok(())
707}
708
709impl fmt::Debug for DefinitionIdentity {
710    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
711        formatter
712            .debug_struct("DefinitionIdentity")
713            .field("job_name", &self.job_name)
714            .field("revision", &self.revision)
715            .field("manifest_format", &self.manifest_format)
716            .field(
717                "digest_prefix",
718                &DigestPrefix([
719                    self.manifest_digest[0],
720                    self.manifest_digest[1],
721                    self.manifest_digest[2],
722                    self.manifest_digest[3],
723                ]),
724            )
725            .field("canonical_manifest", &"<redacted>")
726            .finish()
727    }
728}
729
730struct DigestPrefix([u8; 4]);
731
732impl fmt::Debug for DigestPrefix {
733    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
734        for byte in self.0 {
735            write!(formatter, "{byte:02x}")?;
736        }
737        Ok(())
738    }
739}
740
741/// A validated, read-only view of one canonical definition manifest.
742///
743/// The reader accepts every manifest format this runtime understands, so a
744/// deployment can inspect a definition persisted by an older release. It never
745/// guesses: a newer format, a non-canonical encoding, a floating-point value,
746/// an out-of-bound graph, or a digest that does not match the supplied bytes
747/// fails closed.
748///
749/// The runnable example lives in the `oxide-batch` facade documentation,
750/// because the supported import path is `oxide_batch`.
751#[derive(Clone, Debug, Eq, PartialEq)]
752pub struct DefinitionManifest {
753    format: u16,
754    digest: [u8; 32],
755    job_name: Option<JobName>,
756    node_count: Option<usize>,
757    transition_count: Option<usize>,
758}
759
760impl DefinitionManifest {
761    /// Reads and validates canonical manifest bytes.
762    ///
763    /// # Errors
764    ///
765    /// Returns [`ManifestError`] when the bytes exceed the durable bound, are
766    /// not a canonical JSON object, omit or overflow the format member, declare
767    /// a format this runtime cannot interpret, contain a floating-point value,
768    /// or describe a graph outside the accepted bounds.
769    pub fn read(bytes: &[u8]) -> Result<Self, ManifestError> {
770        if bytes.len() > MAX_MANIFEST_BYTES {
771            return Err(ManifestError::TooLarge {
772                max_bytes: MAX_MANIFEST_BYTES,
773            });
774        }
775        let document: serde_json::Value =
776            serde_json::from_slice(bytes).map_err(|_| ManifestError::MalformedJson)?;
777        let members = document.as_object().ok_or(ManifestError::NotAnObject)?;
778        if contains_float(&document) {
779            return Err(ManifestError::FloatValue);
780        }
781        let reencoded = serde_json::to_vec(&document).map_err(|_| ManifestError::MalformedJson)?;
782        if reencoded != bytes {
783            return Err(ManifestError::NonCanonicalEncoding);
784        }
785        let format = members
786            .get("format")
787            .and_then(serde_json::Value::as_u64)
788            .and_then(|format| u16::try_from(format).ok())
789            .ok_or(ManifestError::MissingFormat)?;
790        check_manifest_format(format)?;
791        let job_name = members
792            .get("job")
793            .and_then(serde_json::Value::as_str)
794            .map(JobName::new)
795            .transpose()
796            .map_err(|_| ManifestError::InvalidJobName)?;
797        let (node_count, transition_count) =
798            if matches!(format, MANIFEST_FORMAT_FLOW | MANIFEST_FORMAT_LOCAL_SCALE) {
799                let nodes = array_len(members.get("nodes"))?;
800                let transitions = array_len(members.get("transitions"))?;
801                if nodes > MAX_NODES || transitions > MAX_TRANSITIONS {
802                    return Err(ManifestError::GraphOutOfBounds {
803                        max_nodes: MAX_NODES,
804                        max_transitions: MAX_TRANSITIONS,
805                    });
806                }
807                (Some(nodes), Some(transitions))
808            } else {
809                (None, None)
810            };
811        Ok(Self {
812            format,
813            digest: Sha256::digest(bytes).into(),
814            job_name,
815            node_count,
816            transition_count,
817        })
818    }
819
820    /// Reads canonical bytes and requires them to hash to `expected`.
821    ///
822    /// # Errors
823    ///
824    /// Returns every [`ManifestError`] [`read`](Self::read) returns, plus
825    /// [`ManifestError::DigestMismatch`] when the bytes were altered.
826    pub fn read_verified(bytes: &[u8], expected: &[u8; 32]) -> Result<Self, ManifestError> {
827        let manifest = Self::read(bytes)?;
828        if &manifest.digest != expected {
829            return Err(ManifestError::DigestMismatch);
830        }
831        Ok(manifest)
832    }
833
834    /// Returns the declared canonical manifest format.
835    #[must_use]
836    pub const fn format(&self) -> u16 {
837        self.format
838    }
839
840    /// Returns the SHA-256 digest of the exact bytes that were read.
841    #[must_use]
842    pub const fn digest(&self) -> &[u8; 32] {
843        &self.digest
844    }
845
846    /// Borrows the job name bound into the manifest, when present.
847    #[must_use]
848    pub const fn job_name(&self) -> Option<&JobName> {
849        self.job_name.as_ref()
850    }
851
852    /// Returns the compiled node count of a flow manifest.
853    #[must_use]
854    pub const fn node_count(&self) -> Option<usize> {
855        self.node_count
856    }
857
858    /// Returns the compiled transition count of a flow manifest.
859    #[must_use]
860    pub const fn transition_count(&self) -> Option<usize> {
861        self.transition_count
862    }
863}
864
865fn array_len(value: Option<&serde_json::Value>) -> Result<usize, ManifestError> {
866    value
867        .and_then(serde_json::Value::as_array)
868        .map(Vec::len)
869        .ok_or(ManifestError::MalformedGraph)
870}
871
872fn contains_float(value: &serde_json::Value) -> bool {
873    match value {
874        serde_json::Value::Number(number) => number.as_i64().is_none() && number.as_u64().is_none(),
875        serde_json::Value::Array(values) => values.iter().any(contains_float),
876        serde_json::Value::Object(members) => members.values().any(contains_float),
877        _ => false,
878    }
879}
880
881/// A canonical definition manifest that cannot be interpreted.
882#[derive(Clone, Copy, Debug, Eq, PartialEq)]
883#[non_exhaustive]
884pub enum ManifestError {
885    /// The bytes exceeded the durable manifest bound.
886    TooLarge {
887        /// Maximum accepted byte length.
888        max_bytes: usize,
889    },
890    /// The bytes are not valid JSON.
891    MalformedJson,
892    /// The document is not a JSON object.
893    NotAnObject,
894    /// Re-encoding the document did not reproduce the supplied bytes.
895    NonCanonicalEncoding,
896    /// The document contains a floating-point number.
897    FloatValue,
898    /// The format member is absent or not a `u16`.
899    MissingFormat,
900    /// The manifest is newer than this runtime understands.
901    UnsupportedFormat {
902        /// Format found in the manifest.
903        format: u16,
904        /// Newest format this runtime interprets.
905        supported: u16,
906    },
907    /// A flow manifest omitted or malformed its graph members.
908    MalformedGraph,
909    /// A flow manifest declared a graph larger than this runtime accepts.
910    GraphOutOfBounds {
911        /// Maximum accepted node count.
912        max_nodes: usize,
913        /// Maximum accepted transition count.
914        max_transitions: usize,
915    },
916    /// The bound job name is not a valid [`JobName`].
917    InvalidJobName,
918    /// The bytes do not hash to the expected fingerprint.
919    DigestMismatch,
920}
921
922impl fmt::Display for ManifestError {
923    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
924        match self {
925            Self::TooLarge { max_bytes } => {
926                write!(formatter, "definition manifest exceeds {max_bytes} bytes")
927            }
928            Self::MalformedJson => formatter.write_str("definition manifest is not valid JSON"),
929            Self::NotAnObject => formatter.write_str("definition manifest is not a JSON object"),
930            Self::NonCanonicalEncoding => {
931                formatter.write_str("definition manifest is not canonically encoded")
932            }
933            Self::FloatValue => {
934                formatter.write_str("definition manifest contains a floating-point value")
935            }
936            Self::MissingFormat => {
937                formatter.write_str("definition manifest has no usable format member")
938            }
939            Self::UnsupportedFormat { format, supported } => write!(
940                formatter,
941                "definition manifest format {format} is newer than the supported format {supported}"
942            ),
943            Self::MalformedGraph => {
944                formatter.write_str("flow manifest has no readable node and transition members")
945            }
946            Self::GraphOutOfBounds {
947                max_nodes,
948                max_transitions,
949            } => write!(
950                formatter,
951                "flow manifest exceeds {max_nodes} nodes or {max_transitions} transitions"
952            ),
953            Self::InvalidJobName => {
954                formatter.write_str("definition manifest binds an invalid job name")
955            }
956            Self::DigestMismatch => {
957                formatter.write_str("definition manifest does not match its fingerprint")
958            }
959        }
960    }
961}
962
963impl Error for ManifestError {}
964
965/// Definition token category used by validation diagnostics.
966#[derive(Clone, Copy, Debug, Eq, PartialEq)]
967#[non_exhaustive]
968pub enum DefinitionTokenKind {
969    /// Definition revision.
970    Revision,
971    /// Opaque component revision.
972    Component,
973    /// Directed compatibility edge key.
974    Upgrade,
975    /// Bounded fault-classifier revision.
976    Classifier,
977    /// Stable flow-graph node identifier.
978    Node,
979    /// Bounded decider revision.
980    Decider,
981}
982
983/// Failure to construct a bounded restart definition.
984#[derive(Clone, Debug, Eq, PartialEq)]
985#[non_exhaustive]
986pub enum DefinitionError {
987    /// A start limit of zero can never start its step.
988    ZeroStartLimit,
989    /// A required token was empty.
990    EmptyToken {
991        /// Rejected token category.
992        kind: DefinitionTokenKind,
993    },
994    /// A token exceeded its UTF-8 byte bound.
995    TokenTooLong {
996        /// Rejected token category.
997        kind: DefinitionTokenKind,
998        /// Maximum accepted byte length.
999        max_bytes: usize,
1000    },
1001    /// A token had leading or trailing whitespace.
1002    SurroundingWhitespace {
1003        /// Rejected token category.
1004        kind: DefinitionTokenKind,
1005    },
1006    /// A token contained a control character.
1007    ControlCharacter {
1008        /// Rejected token category.
1009        kind: DefinitionTokenKind,
1010    },
1011    /// The canonical manifest could not be encoded.
1012    ManifestEncoding,
1013    /// The canonical manifest exceeded its durable bound.
1014    ManifestTooLarge {
1015        /// Maximum accepted byte length.
1016        max_bytes: usize,
1017    },
1018    /// A directed edge pointed from a definition to itself.
1019    UpgradeSelfEdge,
1020    /// A directed edge omitted its durable step mapping.
1021    EmptyStepMapping,
1022    /// A source step appeared more than once.
1023    DuplicateSourceStep,
1024    /// Two source steps mapped to the same target step.
1025    DuplicateTargetStep,
1026    /// A step's fault runtime declared a different delivery mode than its
1027    /// restart contract.
1028    DeliveryModeMismatch,
1029    /// A one-step wrapper could not be lowered into its compatibility plan.
1030    ///
1031    /// The framework derives the compatibility graph from values it has
1032    /// already validated, so this variant reports a framework invariant rather
1033    /// than an application mistake.
1034    CompatibilityLowering,
1035    /// A runtime `ItemStream` was registered under a namespace this
1036    /// definition's [`ChunkComponentRevisions`] does not declare.
1037    RuntimeStreamNotDeclared {
1038        /// The undeclared runtime namespace.
1039        namespace: ComponentStreamIdentity,
1040    },
1041    /// A declared stream revision has no matching runtime `ItemStream`
1042    /// registration.
1043    DeclaredStreamMissingRuntime {
1044        /// The declared namespace with no runtime registration.
1045        namespace: ComponentStreamIdentity,
1046    },
1047    /// The same namespace was registered as a runtime `ItemStream` more than
1048    /// once.
1049    DuplicateRuntimeStream {
1050        /// The namespace registered more than once.
1051        namespace: ComponentStreamIdentity,
1052    },
1053    /// A registered `ItemStream` declares
1054    /// [`RestartabilityDeclaration::NotRestartable`](crate::RestartabilityDeclaration::NotRestartable),
1055    /// which prevents its owning chunk step from claiming restartability
1056    /// regardless of reader-checkpoint presence.
1057    NonRestartableStream {
1058        /// The namespace whose stream cannot honestly claim restartability.
1059        namespace: ComponentStreamIdentity,
1060    },
1061    /// An installed completion policy's `fingerprint()` panicked while its
1062    /// configuration was being folded into the chunk definition's
1063    /// restart-relevant revisions.
1064    ///
1065    /// `fingerprint()` is an application-supplied callback (every built-in
1066    /// policy's own implementation cannot panic), so a definition cannot be
1067    /// constructed from one that does -- this is reported the same way any
1068    /// other definition-construction failure is, rather than unwinding out
1069    /// of a constructor with a `Result` return type.
1070    CompletionPolicyFingerprintPanic,
1071    /// A chunk step's live completion-policy fingerprint does not match the
1072    /// completion-policy revision already declared in the
1073    /// [`ChunkComponentRevisions`] a compiled flow node was bound against.
1074    ///
1075    /// Unlike a standalone chunk job (which builds its compiled plan and
1076    /// runtime step from the same call, so it can fold a completion
1077    /// policy's fingerprint in automatically), a flow node is bound to a
1078    /// plan that was already compiled from bare `ChunkComponentRevisions`
1079    /// -- before any concrete step, or its installed policy, existed. The
1080    /// caller must declare the matching revision up front (the same way a
1081    /// stream revision is declared up front and validated against its
1082    /// runtime registration) rather than relying on it being computed
1083    /// automatically.
1084    CompletionPolicyRevisionMismatch,
1085}
1086
1087impl fmt::Display for DefinitionError {
1088    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1089        match self {
1090            Self::ZeroStartLimit => formatter.write_str("start limit must be nonzero"),
1091            Self::EmptyToken { kind } => write!(formatter, "{kind:?} token must not be empty"),
1092            Self::TokenTooLong { kind, max_bytes } => {
1093                write!(formatter, "{kind:?} token exceeds {max_bytes} bytes")
1094            }
1095            Self::SurroundingWhitespace { kind } => {
1096                write!(formatter, "{kind:?} token has surrounding whitespace")
1097            }
1098            Self::ControlCharacter { kind } => {
1099                write!(formatter, "{kind:?} token contains a control character")
1100            }
1101            Self::ManifestEncoding => formatter.write_str("definition manifest encoding failed"),
1102            Self::ManifestTooLarge { max_bytes } => {
1103                write!(formatter, "definition manifest exceeds {max_bytes} bytes")
1104            }
1105            Self::UpgradeSelfEdge => formatter.write_str("definition upgrade is a self-edge"),
1106            Self::EmptyStepMapping => formatter.write_str("definition upgrade has no step mapping"),
1107            Self::DuplicateSourceStep => {
1108                formatter.write_str("definition upgrade repeats a source step")
1109            }
1110            Self::DuplicateTargetStep => {
1111                formatter.write_str("definition upgrade reuses a target step")
1112            }
1113            Self::DeliveryModeMismatch => formatter
1114                .write_str("fault runtime and restart contract declare different delivery modes"),
1115            Self::CompatibilityLowering => {
1116                formatter.write_str("one-step compatibility lowering produced an invalid plan")
1117            }
1118            Self::RuntimeStreamNotDeclared { namespace } => write!(
1119                formatter,
1120                "runtime ItemStream namespace {:?} is not declared in the chunk definition",
1121                namespace.as_str()
1122            ),
1123            Self::DeclaredStreamMissingRuntime { namespace } => write!(
1124                formatter,
1125                "declared stream namespace {:?} has no registered runtime ItemStream",
1126                namespace.as_str()
1127            ),
1128            Self::DuplicateRuntimeStream { namespace } => write!(
1129                formatter,
1130                "runtime ItemStream namespace {:?} was registered more than once",
1131                namespace.as_str()
1132            ),
1133            Self::NonRestartableStream { namespace } => write!(
1134                formatter,
1135                "ItemStream namespace {:?} declares NotRestartable, so this step cannot claim restartability",
1136                namespace.as_str()
1137            ),
1138            Self::CompletionPolicyFingerprintPanic => {
1139                formatter.write_str("completion policy fingerprint() panicked")
1140            }
1141            Self::CompletionPolicyRevisionMismatch => formatter.write_str(
1142                "the step's live completion-policy fingerprint does not match the \
1143                 completion-policy revision declared in the bound component revisions",
1144            ),
1145        }
1146    }
1147}
1148
1149impl Error for DefinitionError {}