Skip to main content

lodestone_types/
lifecycle_completion.rs

1//! External content-key and aggregate-then-delete contracts.
2//!
3//! This module freezes current-format authority and failure semantics. It does
4//! not encrypt an object, contact a resolver, retire raw data, register a public
5//! operation, or make either lifecycle capability runtime reachable.
6
7use std::error::Error;
8use std::fmt::{self, Display, Formatter};
9use std::num::NonZeroU128;
10
11use serde::de::{DeserializeOwned, Visitor};
12use serde::{Deserialize, Deserializer, Serialize, Serializer};
13
14use crate::{
15    ClusterRevision, CollectionIndexRef, FieldPathDigestV1, ManifestGeneration, PhysicalObjectId, PolicyGeneration,
16    RootSetRef, SchemaGeneration, SeqNo, ShardId, SourceCutV1, SourceIncarnation,
17};
18
19/// Current aggregate-then-delete contract and object-codec format.
20pub const LIFECYCLE_COMPLETION_FORMAT_VERSION: u16 = 2;
21/// Current external-key binding and encrypted-object metadata format.
22pub const EXTERNAL_KEY_FORMAT_VERSION: u16 = 3;
23pub const MAX_LIFECYCLE_CONTRACT_BYTES: usize = 1_048_576;
24pub const MAX_OPAQUE_EXTERNAL_KEY_BINDING_BYTES: usize = 4 * 1_024;
25pub const MAX_ROLLUP_DIMENSIONS: usize = 32;
26pub const MAX_ROLLUP_MEASURES: usize = 64;
27pub const MAX_ROLLUP_COVERAGES: usize = 4_096;
28pub const MAX_ROLLUP_SHARDS: usize = 512;
29pub const MAX_ROLLUP_OUTPUT_ROWS: usize = 262_144;
30pub const MAX_ROLLUP_OUTPUT_BYTES: usize = 16 * 1_024 * 1_024;
31pub const MAX_ROLLUP_SCALAR_BYTES: usize = 4_096;
32pub const MAX_ROLLUP_BUCKET_MILLIS: u64 = 366 * 24 * 60 * 60 * 1_000;
33pub const MAX_CONTENT_KEY_CACHE_MILLIS: u64 = 300_000;
34pub const ROLLUP_QUERY_CONTRACT_DIGEST_V1: [u8; 32] = [
35    0x7c, 0x3e, 0x41, 0x11, 0x81, 0xaa, 0x97, 0x39, 0x2d, 0x63, 0x22, 0xcd, 0xd8, 0xf0, 0x02, 0x07, 0xea, 0x5f, 0x06,
36    0xa7, 0x90, 0x6b, 0x28, 0x81, 0x42, 0x89, 0x9c, 0xb8, 0x02, 0x17, 0x57, 0x80,
37];
38
39fn digest_is_nonzero(value: &[u8; 32]) -> bool {
40    value.iter().any(|byte| *byte != 0)
41}
42
43fn require_digest(value: &[u8; 32], name: &'static str) -> Result<(), LifecycleContractError> {
44    if digest_is_nonzero(value) {
45        Ok(())
46    } else {
47        Err(LifecycleContractError::Violation(name))
48    }
49}
50
51macro_rules! semantic_digest {
52    ($name:ident, $description:literal) => {
53        #[doc = $description]
54        #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
55        #[serde(transparent)]
56        pub struct $name([u8; 32]);
57
58        impl $name {
59            pub fn new(value: [u8; 32]) -> Result<Self, LifecycleContractError> {
60                require_digest(&value, concat!(stringify!($name), " cannot be zero"))?;
61                Ok(Self(value))
62            }
63
64            #[must_use]
65            pub const fn get(self) -> [u8; 32] {
66                self.0
67            }
68
69            fn validate(self) -> Result<(), LifecycleContractError> {
70                require_digest(&self.0, concat!(stringify!($name), " cannot be zero"))
71            }
72        }
73    };
74}
75
76macro_rules! nonzero_decimal_identity {
77    ($name:ident, $description:literal) => {
78        #[doc = $description]
79        #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
80        pub struct $name(NonZeroU128);
81
82        impl $name {
83            pub fn new(value: u128) -> Result<Self, LifecycleContractError> {
84                NonZeroU128::new(value)
85                    .map(Self)
86                    .ok_or(LifecycleContractError::ZeroIdentity(stringify!($name)))
87            }
88
89            #[must_use]
90            pub const fn get(self) -> u128 {
91                self.0.get()
92            }
93        }
94
95        impl Display for $name {
96            fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
97                Display::fmt(&self.get(), formatter)
98            }
99        }
100
101        impl Serialize for $name {
102            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
103            where
104                S: Serializer,
105            {
106                serializer.serialize_str(&self.get().to_string())
107            }
108        }
109
110        impl<'de> Deserialize<'de> for $name {
111            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
112            where
113                D: Deserializer<'de>,
114            {
115                struct CanonicalDecimalVisitor;
116
117                impl Visitor<'_> for CanonicalDecimalVisitor {
118                    type Value = $name;
119
120                    fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
121                        formatter.write_str("a canonical nonzero u128 decimal string")
122                    }
123
124                    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
125                    where
126                        E: serde::de::Error,
127                    {
128                        let bytes = value.as_bytes();
129                        if bytes.is_empty()
130                            || !matches!(bytes[0], b'1'..=b'9')
131                            || !bytes[1..].iter().all(u8::is_ascii_digit)
132                        {
133                            return Err(E::custom("expected a canonical nonzero u128 decimal string"));
134                        }
135                        let parsed = value
136                            .parse::<u128>()
137                            .map_err(|_| E::custom("identity exceeds u128"))?;
138                        NonZeroU128::new(parsed)
139                            .map($name)
140                            .ok_or_else(|| E::custom("identity cannot be zero"))
141                    }
142                }
143
144                deserializer.deserialize_str(CanonicalDecimalVisitor)
145            }
146        }
147    };
148}
149
150semantic_digest!(
151    ExternalKeyContextDigestV1,
152    "Digest of the exact non-secret external-key context."
153);
154semantic_digest!(
155    RollupQueryContractDigestV1,
156    "Digest of the admitted query shapes proven by the rollup planner."
157);
158semantic_digest!(
159    RollupCoverageDigestV1,
160    "Digest of the exact coverage/root state proven safe for routing."
161);
162nonzero_decimal_identity!(ContentKeyIdV1, "Never-reused identity of one content-encryption key.");
163nonzero_decimal_identity!(
164    ExternalKeyBindingIdV1,
165    "Never-reused identity of one opaque index-authorized external-key binding."
166);
167nonzero_decimal_identity!(
168    RollupPolicyIdV1,
169    "Never-reused identity of one aggregate-then-delete policy."
170);
171
172/// Only the current authenticated document-object algorithm is admitted.
173#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
174#[serde(rename_all = "snake_case")]
175pub enum DocumentEncryptionAlgorithmV1 {
176    Aes256Gcm,
177}
178
179/// Exact non-secret context authenticated by the external resolver.
180#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
181#[serde(deny_unknown_fields)]
182pub struct ExternalKeyContextV1 {
183    pub format_version: u16,
184    pub collection_index_ref: CollectionIndexRef,
185    pub content_key_id: ContentKeyIdV1,
186    pub content_key_generation: u64,
187    pub binding_id: ExternalKeyBindingIdV1,
188    pub binding_generation: u64,
189    pub access_generation: u64,
190    pub object_context_digest: [u8; 32],
191}
192
193impl ExternalKeyContextV1 {
194    pub fn validate(self) -> Result<(), LifecycleContractError> {
195        validate_external_key_format(self.format_version)?;
196        if self.content_key_generation == 0 || self.binding_generation == 0 || self.access_generation == 0 {
197            return Err(LifecycleContractError::Violation(
198                "external-key generations cannot be zero",
199            ));
200        }
201        require_digest(
202            &self.object_context_digest,
203            "external-key object context cannot be zero",
204        )
205    }
206
207    pub fn digest(self) -> Result<ExternalKeyContextDigestV1, LifecycleContractError> {
208        self.validate()?;
209        let bytes = serde_json::to_vec(&self).map_err(LifecycleContractError::serde)?;
210        ExternalKeyContextDigestV1::new(*blake3::hash(&bytes).as_bytes())
211    }
212}
213
214/// Opaque external content-key binding. Core never interprets its bytes.
215#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
216#[serde(deny_unknown_fields)]
217pub struct IndexExternalKeyBindingV1 {
218    pub format_version: u16,
219    pub binding_id: ExternalKeyBindingIdV1,
220    pub collection_index_ref: CollectionIndexRef,
221    pub content_key_id: ContentKeyIdV1,
222    pub content_key_generation: u64,
223    pub binding_generation: u64,
224    pub access_generation: u64,
225    pub opaque_binding: Vec<u8>,
226    pub context: ExternalKeyContextV1,
227    pub context_digest: ExternalKeyContextDigestV1,
228    pub created_at_revision: ClusterRevision,
229}
230
231impl fmt::Debug for IndexExternalKeyBindingV1 {
232    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
233        formatter
234            .debug_struct("IndexExternalKeyBindingV1")
235            .field("format_version", &self.format_version)
236            .field("binding_id", &self.binding_id)
237            .field("collection_index_ref", &self.collection_index_ref)
238            .field("content_key_id", &self.content_key_id)
239            .field("content_key_generation", &self.content_key_generation)
240            .field("binding_generation", &self.binding_generation)
241            .field("access_generation", &self.access_generation)
242            .field("opaque_binding", &"[redacted]")
243            .field("context", &self.context)
244            .field("context_digest", &self.context_digest)
245            .field("created_at_revision", &self.created_at_revision)
246            .finish()
247    }
248}
249
250impl IndexExternalKeyBindingV1 {
251    pub fn validate(&self) -> Result<(), LifecycleContractError> {
252        validate_external_key_format(self.format_version)?;
253        if self.content_key_generation == 0
254            || self.binding_generation == 0
255            || self.access_generation == 0
256            || self.created_at_revision.get() == 0
257        {
258            return Err(LifecycleContractError::Violation(
259                "external-key binding requires nonzero generations",
260            ));
261        }
262        if self.opaque_binding.is_empty()
263            || self.opaque_binding.len() > MAX_OPAQUE_EXTERNAL_KEY_BINDING_BYTES
264            || self.opaque_binding.iter().all(|byte| *byte == 0)
265        {
266            return Err(LifecycleContractError::Budget {
267                field: "opaque external-key binding bytes",
268                observed: self.opaque_binding.len() as u64,
269                maximum: MAX_OPAQUE_EXTERNAL_KEY_BINDING_BYTES as u64,
270            });
271        }
272        self.context.validate()?;
273        self.context_digest.validate()?;
274        if self.context.collection_index_ref != self.collection_index_ref
275            || self.context.content_key_id != self.content_key_id
276            || self.context.content_key_generation != self.content_key_generation
277            || self.context.binding_id != self.binding_id
278            || self.context.binding_generation != self.binding_generation
279            || self.context.access_generation != self.access_generation
280            || self.context.digest()? != self.context_digest
281        {
282            return Err(LifecycleContractError::Violation(
283                "external-key binding and context identities differ",
284            ));
285        }
286        Ok(())
287    }
288}
289
290/// Immutable document-store metadata for one encrypted object. Mutable
291/// resolver binding and access generations intentionally do not appear here:
292/// rotating them must not require rewriting immutable ciphertext.
293#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
294#[serde(deny_unknown_fields)]
295pub struct EncryptedObjectKeyRefV1 {
296    pub format_version: u16,
297    pub physical_object: PhysicalObjectId,
298    pub algorithm: DocumentEncryptionAlgorithmV1,
299    pub content_key_id: ContentKeyIdV1,
300    pub content_key_generation: u64,
301    pub nonce: [u8; 12],
302    pub authenticated_context_digest: [u8; 32],
303    pub ciphertext_bytes: u64,
304}
305
306impl EncryptedObjectKeyRefV1 {
307    pub fn validate(&self) -> Result<(), LifecycleContractError> {
308        validate_external_key_format(self.format_version)?;
309        self.physical_object
310            .validate()
311            .map_err(|error| LifecycleContractError::Nested(error.to_string()))?;
312        if self.content_key_generation == 0 || self.ciphertext_bytes < 16 {
313            return Err(LifecycleContractError::Violation(
314                "encrypted objects require a nonzero key generation and an authentication tag",
315            ));
316        }
317        if self.nonce != object_nonce_v1(self.content_key_id, self.content_key_generation, &self.physical_object)? {
318            return Err(LifecycleContractError::Violation(
319                "encrypted-object nonce is not derived from its never-reused physical identity",
320            ));
321        }
322        let expected = object_authenticated_context_digest_v1(
323            self.algorithm,
324            self.content_key_id,
325            self.content_key_generation,
326            &self.physical_object,
327            self.ciphertext_bytes,
328        )?;
329        if self.authenticated_context_digest != expected {
330            return Err(LifecycleContractError::Violation(
331                "encrypted-object authenticated context does not exact-match metadata",
332            ));
333        }
334        Ok(())
335    }
336}
337
338/// Derives a deterministic 96-bit nonce from a never-reused physical object
339/// identity and content-key generation. Re-encrypting another payload under
340/// the same physical identity is forbidden by immutable-object publication.
341pub fn object_nonce_v1(
342    content_key_id: ContentKeyIdV1,
343    content_key_generation: u64,
344    physical_object: &PhysicalObjectId,
345) -> Result<[u8; 12], LifecycleContractError> {
346    if content_key_generation == 0 {
347        return Err(LifecycleContractError::Violation(
348            "content-key generation cannot be zero",
349        ));
350    }
351    physical_object
352        .validate()
353        .map_err(|error| LifecycleContractError::Nested(error.to_string()))?;
354    let mut hasher = blake3::Hasher::new();
355    hasher.update(b"lodestone/p13/object-nonce/v1\0");
356    hasher.update(&content_key_id.get().to_be_bytes());
357    hasher.update(&content_key_generation.to_be_bytes());
358    hasher.update(&serde_json::to_vec(physical_object).map_err(LifecycleContractError::serde)?);
359    let digest = hasher.finalize();
360    let mut nonce = [0_u8; 12];
361    nonce.copy_from_slice(&digest.as_bytes()[..12]);
362    Ok(nonce)
363}
364
365pub fn object_authenticated_context_digest_v1(
366    algorithm: DocumentEncryptionAlgorithmV1,
367    content_key_id: ContentKeyIdV1,
368    content_key_generation: u64,
369    physical_object: &PhysicalObjectId,
370    ciphertext_bytes: u64,
371) -> Result<[u8; 32], LifecycleContractError> {
372    if content_key_generation == 0 || ciphertext_bytes < 16 {
373        return Err(LifecycleContractError::Violation(
374            "authenticated context requires a key generation and ciphertext tag",
375        ));
376    }
377    physical_object
378        .validate()
379        .map_err(|error| LifecycleContractError::Nested(error.to_string()))?;
380    let binding = (
381        EXTERNAL_KEY_FORMAT_VERSION,
382        algorithm,
383        content_key_id,
384        content_key_generation,
385        physical_object,
386        ciphertext_bytes,
387    );
388    let mut hasher = blake3::Hasher::new();
389    hasher.update(b"lodestone/p13/object-aad/v1\0");
390    hasher.update(&serde_json::to_vec(&binding).map_err(LifecycleContractError::serde)?);
391    Ok(*hasher.finalize().as_bytes())
392}
393
394#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
395#[serde(rename_all = "snake_case")]
396pub enum RollupTimeZoneV1 {
397    Utc,
398}
399
400#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
401#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
402pub enum RollupMeasureV1 {
403    Count,
404    Sum { field_path_digest: FieldPathDigestV1 },
405    Min { field_path_digest: FieldPathDigestV1 },
406    Max { field_path_digest: FieldPathDigestV1 },
407}
408
409#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
410#[serde(deny_unknown_fields)]
411pub struct RollupPolicyV1 {
412    pub format_version: u16,
413    pub collection_index_ref: CollectionIndexRef,
414    pub policy_id: RollupPolicyIdV1,
415    pub policy_generation: PolicyGeneration,
416    /// Explicit acknowledgement that incompatible detail queries will fail
417    /// after this policy retires their raw coverage.
418    pub detail_retirement_acknowledged: bool,
419    pub time_zone: RollupTimeZoneV1,
420    pub timestamp_field_digest: FieldPathDigestV1,
421    pub bucket_width_millis: u64,
422    pub bucket_alignment_millis: u64,
423    pub dimensions: Vec<FieldPathDigestV1>,
424    pub measures: Vec<RollupMeasureV1>,
425}
426
427impl RollupPolicyV1 {
428    pub fn validate(&self) -> Result<(), LifecycleContractError> {
429        validate_format(self.format_version)?;
430        if self.policy_generation.get() == 0
431            || !self.detail_retirement_acknowledged
432            || self.bucket_width_millis == 0
433            || self.bucket_width_millis > MAX_ROLLUP_BUCKET_MILLIS
434            || self.bucket_alignment_millis >= self.bucket_width_millis
435        {
436            return Err(LifecycleContractError::Violation(
437                "rollup generation or bucket geometry is outside the current contract",
438            ));
439        }
440        require_digest(
441            &self.timestamp_field_digest.get(),
442            "rollup timestamp-field digest cannot be zero",
443        )?;
444        if self.dimensions.len() > MAX_ROLLUP_DIMENSIONS
445            || self.dimensions.windows(2).any(|pair| pair[0] >= pair[1])
446            || self.measures.is_empty()
447            || self.measures.len() > MAX_ROLLUP_MEASURES
448            || self.measures.windows(2).any(|pair| pair[0] >= pair[1])
449        {
450            return Err(LifecycleContractError::Violation(
451                "rollup dimensions and measures must be bounded, unique, and canonically ordered",
452            ));
453        }
454        for dimension in &self.dimensions {
455            require_digest(&dimension.get(), "rollup dimension digest cannot be zero")?;
456        }
457        for measure in &self.measures {
458            match measure {
459                RollupMeasureV1::Count => {}
460                RollupMeasureV1::Sum { field_path_digest }
461                | RollupMeasureV1::Min { field_path_digest }
462                | RollupMeasureV1::Max { field_path_digest } => {
463                    require_digest(&field_path_digest.get(), "rollup measure digest cannot be zero")?;
464                }
465            }
466        }
467        Ok(())
468    }
469
470    pub fn boundary_aligned(&self, value: u64) -> bool {
471        value >= self.bucket_alignment_millis
472            && (value - self.bucket_alignment_millis).is_multiple_of(self.bucket_width_millis)
473    }
474}
475
476/// Immutable aggregate-then-delete input fence. This is embedded in the sole
477/// leased maintenance task and deliberately excludes staged output and
478/// resumable progress.
479#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
480#[serde(deny_unknown_fields)]
481pub struct RollupWorkPlanV1 {
482    pub format_version: u16,
483    pub policy: RollupPolicyV1,
484    pub schema_generation: SchemaGeneration,
485    pub source_incarnation: Option<SourceIncarnation>,
486    pub source_cut: Option<SourceCutV1>,
487    pub source_generations: Vec<RollupSourceGenerationV1>,
488    pub range_start_millis: u64,
489    pub range_end_millis: u64,
490    pub late_data_watermark_millis: u64,
491}
492
493impl RollupWorkPlanV1 {
494    pub fn validate(&self) -> Result<(), LifecycleContractError> {
495        validate_format(self.format_version)?;
496        self.policy.validate()?;
497        if self.schema_generation.get() == 0
498            || self.range_start_millis >= self.range_end_millis
499            || !self.policy.boundary_aligned(self.range_start_millis)
500            || !self.policy.boundary_aligned(self.range_end_millis)
501            || self.late_data_watermark_millis < self.range_end_millis
502        {
503            return Err(LifecycleContractError::Violation(
504                "rollup work plan schema, aligned range, or late-data watermark is invalid",
505            ));
506        }
507        if self.source_generations.is_empty()
508            || self.source_generations.len() > MAX_ROLLUP_SHARDS
509            || self
510                .source_generations
511                .windows(2)
512                .any(|pair| pair[0].shard_id >= pair[1].shard_id)
513            || self
514                .source_generations
515                .iter()
516                .any(|source| source.manifest_generation.get() == 0)
517        {
518            return Err(LifecycleContractError::Violation(
519                "rollup source generations must be complete, bounded, and canonically ordered",
520            ));
521        }
522        match (&self.source_incarnation, &self.source_cut) {
523            (None, None) => Ok(()),
524            (Some(incarnation), Some(cut)) if *incarnation == cut.source_incarnation => cut
525                .validate()
526                .map_err(|error| LifecycleContractError::Nested(error.to_string())),
527            _ => Err(LifecycleContractError::Violation(
528                "replayable-source rollup work requires an exact matching source cut",
529            )),
530        }
531    }
532
533    pub fn digest(&self) -> Result<[u8; 32], LifecycleContractError> {
534        self.validate()?;
535        let bytes = serde_json::to_vec(self).map_err(LifecycleContractError::serde)?;
536        let mut hasher = blake3::Hasher::new();
537        hasher.update(b"lodestone/p13/rollup-work-plan/v1\0");
538        hasher.update(&bytes);
539        Ok(*hasher.finalize().as_bytes())
540    }
541}
542
543/// Canonical dimension value retained in a rollup row. Floating-point values
544/// are intentionally excluded so grouping and replay remain byte-exact.
545#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
546#[serde(tag = "kind", content = "value", rename_all = "snake_case", deny_unknown_fields)]
547pub enum RollupScalarV1 {
548    Null,
549    Bool(bool),
550    I64(i64),
551    U64(u64),
552    String(String),
553}
554
555impl RollupScalarV1 {
556    fn validate(&self) -> Result<(), LifecycleContractError> {
557        if matches!(self, Self::String(value) if value.len() > MAX_ROLLUP_SCALAR_BYTES) {
558            return Err(LifecycleContractError::Budget {
559                field: "rollup dimension scalar bytes",
560                observed: match self {
561                    Self::String(value) => value.len() as u64,
562                    _ => 0,
563                },
564                maximum: MAX_ROLLUP_SCALAR_BYTES as u64,
565            });
566        }
567        Ok(())
568    }
569}
570
571/// One measure cell in policy order. The tag prevents a count from being
572/// confused with an arithmetically equal sum/min/max during replay.
573#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
574#[serde(tag = "kind", content = "value", rename_all = "snake_case", deny_unknown_fields)]
575pub enum RollupAggregateValueV1 {
576    Count(u64),
577    Sum(i64),
578    Min(i64),
579    Max(i64),
580}
581
582#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
583#[serde(deny_unknown_fields)]
584pub struct RollupOutputRowV1 {
585    pub bucket_start_millis: u64,
586    pub dimensions: Vec<RollupScalarV1>,
587    pub measures: Vec<RollupAggregateValueV1>,
588}
589
590/// Current-format deterministic aggregate object. It binds the complete work
591/// plan rather than becoming a second policy, source, or coverage authority.
592#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
593#[serde(deny_unknown_fields)]
594pub struct RollupOutputV1 {
595    pub format_version: u16,
596    pub work_plan_digest: [u8; 32],
597    pub rows: Vec<RollupOutputRowV1>,
598    pub input_documents: u64,
599    pub input_bytes: u64,
600}
601
602impl RollupOutputV1 {
603    pub fn validate(&self, plan: &RollupWorkPlanV1) -> Result<(), LifecycleContractError> {
604        validate_format(self.format_version)?;
605        plan.validate()?;
606        if self.work_plan_digest != plan.digest()?
607            || self.rows.is_empty()
608            || self.rows.len() > MAX_ROLLUP_OUTPUT_ROWS
609            || u64::try_from(self.rows.len()).is_err()
610            || u64::try_from(self.rows.len()).is_ok_and(|rows| rows > self.input_documents)
611            || self.input_documents == 0
612            || self.input_bytes == 0
613        {
614            return Err(LifecycleContractError::Violation(
615                "rollup output does not bind the exact bounded work plan and input",
616            ));
617        }
618        let mut previous_key: Option<(u64, &[RollupScalarV1])> = None;
619        let count_position = plan
620            .policy
621            .measures
622            .iter()
623            .position(|measure| *measure == RollupMeasureV1::Count);
624        let mut counted_documents = 0_u64;
625        for row in &self.rows {
626            if row.bucket_start_millis < plan.range_start_millis
627                || row.bucket_start_millis >= plan.range_end_millis
628                || !plan.policy.boundary_aligned(row.bucket_start_millis)
629                || row.dimensions.len() != plan.policy.dimensions.len()
630                || row.measures.len() != plan.policy.measures.len()
631                || row.dimensions.iter().any(|value| value.validate().is_err())
632                || row.measures.iter().zip(&plan.policy.measures).any(|(value, measure)| {
633                    !matches!(
634                        (value, measure),
635                        (RollupAggregateValueV1::Count(_), RollupMeasureV1::Count)
636                            | (RollupAggregateValueV1::Sum(_), RollupMeasureV1::Sum { .. })
637                            | (RollupAggregateValueV1::Min(_), RollupMeasureV1::Min { .. })
638                            | (RollupAggregateValueV1::Max(_), RollupMeasureV1::Max { .. })
639                    )
640                })
641                || previous_key.is_some_and(|prior| prior >= (row.bucket_start_millis, &row.dimensions))
642            {
643                return Err(LifecycleContractError::Violation(
644                    "rollup rows must match policy shape and be canonically ordered",
645                ));
646            }
647            if let Some(position) = count_position {
648                let RollupAggregateValueV1::Count(count) = row.measures[position] else {
649                    return Err(LifecycleContractError::Violation(
650                        "rollup count measure does not match the current policy",
651                    ));
652                };
653                if count == 0 {
654                    return Err(LifecycleContractError::Violation(
655                        "rollup groups cannot contain an empty count",
656                    ));
657                }
658                counted_documents = counted_documents
659                    .checked_add(count)
660                    .ok_or(LifecycleContractError::Violation("rollup count total overflowed"))?;
661            }
662            previous_key = Some((row.bucket_start_millis, &row.dimensions));
663        }
664        if count_position.is_some() && counted_documents != self.input_documents {
665            return Err(LifecycleContractError::Violation(
666                "rollup count total does not match the exact input document count",
667            ));
668        }
669        Ok(())
670    }
671}
672
673#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
674#[serde(deny_unknown_fields)]
675pub struct RollupSourceGenerationV1 {
676    pub shard_id: ShardId,
677    pub manifest_generation: ManifestGeneration,
678    pub correction_through: SeqNo,
679}
680
681#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
682#[serde(deny_unknown_fields)]
683pub struct RollupRoutingProofV1 {
684    pub query_contract_digest: RollupQueryContractDigestV1,
685    pub coverage_digest: RollupCoverageDigestV1,
686    pub proven_at_revision: ClusterRevision,
687}
688
689impl RollupRoutingProofV1 {
690    pub fn validate(self) -> Result<(), LifecycleContractError> {
691        self.query_contract_digest.validate()?;
692        self.coverage_digest.validate()?;
693        if self.query_contract_digest != rollup_query_contract_digest_v1()? || self.proven_at_revision.get() == 0 {
694            return Err(LifecycleContractError::Violation(
695                "rollup routing proof requires the current exact query contract and a nonzero catalog revision",
696            ));
697        }
698        Ok(())
699    }
700}
701
702/// Digest of the sole current planner semantics that may authorize raw-data
703/// retirement. A nonzero caller-selected digest is not sufficient evidence.
704pub fn rollup_query_contract_digest_v1() -> Result<RollupQueryContractDigestV1, LifecycleContractError> {
705    let mut hasher = blake3::Hasher::new();
706    hasher.update(b"lodestone/p13/rollup-query-contract/v1\0");
707    hasher.update(
708        b"utc-fixed-buckets;closed-count-sum-min-max;half-open-gap-free-non-overlapping;exact-match-or-raw-unavailable",
709    );
710    let observed = *hasher.finalize().as_bytes();
711    if observed != ROLLUP_QUERY_CONTRACT_DIGEST_V1 {
712        return Err(LifecycleContractError::Violation(
713            "rollup query semantics changed without a current-format digest revision",
714        ));
715    }
716    RollupQueryContractDigestV1::new(observed)
717}
718
719#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
720#[serde(rename_all = "snake_case")]
721pub enum RawRetirementStateV1 {
722    RawPresent,
723    RoutingProven,
724    RawRetired,
725}
726
727#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
728#[serde(deny_unknown_fields)]
729pub struct RollupCoverageV1 {
730    pub format_version: u16,
731    pub collection_index_ref: CollectionIndexRef,
732    pub policy: RollupPolicyV1,
733    pub schema_generation: SchemaGeneration,
734    pub source_incarnation: Option<SourceIncarnation>,
735    pub source_cut: Option<SourceCutV1>,
736    pub source_generations: Vec<RollupSourceGenerationV1>,
737    pub range_start_millis: u64,
738    pub range_end_millis: u64,
739    pub late_data_watermark_millis: u64,
740    pub output_root: RootSetRef,
741    pub raw_retirement_state: RawRetirementStateV1,
742    pub routing_proof: Option<RollupRoutingProofV1>,
743}
744
745impl RollupCoverageV1 {
746    pub fn coverage_digest(&self) -> Result<RollupCoverageDigestV1, LifecycleContractError> {
747        let binding = (
748            self.format_version,
749            self.collection_index_ref,
750            &self.policy,
751            self.schema_generation,
752            self.source_incarnation,
753            &self.source_cut,
754            &self.source_generations,
755            self.range_start_millis,
756            self.range_end_millis,
757            self.late_data_watermark_millis,
758            &self.output_root,
759        );
760        let mut hasher = blake3::Hasher::new();
761        hasher.update(b"lodestone/p13/rollup-coverage/v1\0");
762        hasher.update(&serde_json::to_vec(&binding).map_err(LifecycleContractError::serde)?);
763        RollupCoverageDigestV1::new(*hasher.finalize().as_bytes())
764    }
765
766    pub fn validate(&self) -> Result<(), LifecycleContractError> {
767        validate_format(self.format_version)?;
768        self.policy.validate()?;
769        if self.policy.collection_index_ref != self.collection_index_ref
770            || self.schema_generation.get() == 0
771            || self.range_start_millis >= self.range_end_millis
772            || !self.policy.boundary_aligned(self.range_start_millis)
773            || !self.policy.boundary_aligned(self.range_end_millis)
774            || self.late_data_watermark_millis < self.range_end_millis
775        {
776            return Err(LifecycleContractError::Violation(
777                "rollup coverage scope, schema, aligned range, or late-data watermark is invalid",
778            ));
779        }
780        if self.source_generations.is_empty()
781            || self.source_generations.len() > MAX_ROLLUP_SHARDS
782            || self
783                .source_generations
784                .windows(2)
785                .any(|pair| pair[0].shard_id >= pair[1].shard_id)
786            || self
787                .source_generations
788                .iter()
789                .any(|source| source.manifest_generation.get() == 0)
790        {
791            return Err(LifecycleContractError::Violation(
792                "rollup source generations must be complete, bounded, and canonically ordered",
793            ));
794        }
795        match (&self.source_incarnation, &self.source_cut) {
796            (None, None) => {}
797            (Some(incarnation), Some(cut)) if *incarnation == cut.source_incarnation => cut
798                .validate()
799                .map_err(|error| LifecycleContractError::Nested(error.to_string()))?,
800            _ => {
801                return Err(LifecycleContractError::Violation(
802                    "replayable-source rollup coverage requires an exact matching source cut",
803                ));
804            }
805        }
806        self.output_root
807            .validate()
808            .map_err(|error| LifecycleContractError::Nested(error.to_string()))?;
809        if self.output_root.item_count != 1
810            || self.output_root.page_count != 1
811            || self.output_root.schema_generations.as_slice() != [self.schema_generation]
812        {
813            return Err(LifecycleContractError::Violation(
814                "current rollup coverage requires one output object for the exact schema generation",
815            ));
816        }
817        match (self.raw_retirement_state, self.routing_proof) {
818            (RawRetirementStateV1::RawPresent, None) => {}
819            (RawRetirementStateV1::RoutingProven | RawRetirementStateV1::RawRetired, Some(proof)) => {
820                proof.validate()?;
821                if proof.coverage_digest != self.coverage_digest()? {
822                    return Err(LifecycleContractError::Violation(
823                        "raw-retirement proof does not bind the exact current coverage and output root",
824                    ));
825                }
826            }
827            _ => {
828                return Err(LifecycleContractError::Violation(
829                    "raw retirement requires an exact planner routing proof",
830                ));
831            }
832        }
833        Ok(())
834    }
835}
836
837#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
838#[serde(deny_unknown_fields)]
839pub struct RollupCoverageLedgerV1 {
840    pub format_version: u16,
841    pub collection_index_ref: CollectionIndexRef,
842    pub revision: u64,
843    pub coverages: Vec<RollupCoverageV1>,
844}
845
846impl RollupCoverageLedgerV1 {
847    pub fn validate(&self) -> Result<(), LifecycleContractError> {
848        validate_format(self.format_version)?;
849        if self.revision == 0 || self.coverages.len() > MAX_ROLLUP_COVERAGES {
850            return Err(LifecycleContractError::Budget {
851                field: "rollup coverage rows or revision",
852                observed: self.coverages.len() as u64,
853                maximum: MAX_ROLLUP_COVERAGES as u64,
854            });
855        }
856        let mut prior_end = None;
857        for coverage in &self.coverages {
858            coverage.validate()?;
859            if coverage.collection_index_ref != self.collection_index_ref
860                || prior_end.is_some_and(|end| end > coverage.range_start_millis)
861                || coverage
862                    .routing_proof
863                    .is_some_and(|proof| proof.proven_at_revision.get() > self.revision)
864            {
865                return Err(LifecycleContractError::Violation(
866                    "rollup coverages must be non-overlapping and canonically ordered for one index",
867                ));
868            }
869            prior_end = Some(coverage.range_end_millis);
870        }
871        Ok(())
872    }
873}
874
875#[derive(Clone, Debug, Eq, PartialEq)]
876pub struct RollupQueryShapeV1 {
877    pub range_start_millis: u64,
878    pub range_end_millis: u64,
879    pub time_zone: RollupTimeZoneV1,
880    pub bucket_width_millis: u64,
881    pub bucket_alignment_millis: u64,
882    pub dimensions: Vec<FieldPathDigestV1>,
883    pub measures: Vec<RollupMeasureV1>,
884}
885
886impl RollupQueryShapeV1 {
887    fn matches(&self, policy: &RollupPolicyV1) -> bool {
888        self.time_zone == policy.time_zone
889            && self.bucket_width_millis == policy.bucket_width_millis
890            && self.bucket_alignment_millis == policy.bucket_alignment_millis
891            && self.dimensions == policy.dimensions
892            && self.measures == policy.measures
893            && policy.boundary_aligned(self.range_start_millis)
894            && policy.boundary_aligned(self.range_end_millis)
895    }
896}
897
898#[derive(Clone, Copy, Debug, Eq, PartialEq)]
899pub enum RollupRouteKindV1 {
900    Raw,
901    Rollup,
902}
903
904#[derive(Clone, Copy, Debug, Eq, PartialEq)]
905pub struct RollupRouteSliceV1 {
906    pub range_start_millis: u64,
907    pub range_end_millis: u64,
908    pub kind: RollupRouteKindV1,
909    /// Exact coverage/output-root identity for a rollup slice. Raw slices do
910    /// not carry a coverage identity.
911    pub rollup_coverage_digest: Option<RollupCoverageDigestV1>,
912}
913
914/// Builds an exact, gap-free, non-overlapping route. A query whose semantics
915/// do not match already-retired raw coverage refuses instead of approximating.
916pub fn route_rollup_query_v1(
917    ledger: &RollupCoverageLedgerV1,
918    query: &RollupQueryShapeV1,
919) -> Result<Vec<RollupRouteSliceV1>, LifecycleContractError> {
920    ledger.validate()?;
921    if query.range_start_millis >= query.range_end_millis {
922        return Err(LifecycleContractError::Violation(
923            "rollup query range is empty or reversed",
924        ));
925    }
926    let mut slices = Vec::new();
927    let mut cursor = query.range_start_millis;
928    for coverage in &ledger.coverages {
929        if coverage.range_end_millis <= cursor || coverage.range_start_millis >= query.range_end_millis {
930            continue;
931        }
932        if cursor < coverage.range_start_millis {
933            push_route_slice(
934                &mut slices,
935                cursor,
936                coverage.range_start_millis.min(query.range_end_millis),
937                RollupRouteKindV1::Raw,
938                None,
939            );
940            cursor = coverage.range_start_millis.min(query.range_end_millis);
941        }
942        if cursor >= query.range_end_millis {
943            break;
944        }
945        let end = coverage.range_end_millis.min(query.range_end_millis);
946        let exact_rollup = query.matches(&coverage.policy);
947        if !exact_rollup && coverage.raw_retirement_state == RawRetirementStateV1::RawRetired {
948            return Err(LifecycleContractError::DetailRetiredByPolicy);
949        }
950        let rollup_coverage_digest = exact_rollup.then(|| coverage.coverage_digest()).transpose()?;
951        push_route_slice(
952            &mut slices,
953            cursor.max(coverage.range_start_millis),
954            end,
955            if exact_rollup {
956                RollupRouteKindV1::Rollup
957            } else {
958                RollupRouteKindV1::Raw
959            },
960            rollup_coverage_digest,
961        );
962        cursor = end;
963    }
964    if cursor < query.range_end_millis {
965        push_route_slice(
966            &mut slices,
967            cursor,
968            query.range_end_millis,
969            RollupRouteKindV1::Raw,
970            None,
971        );
972    }
973    if slices.is_empty()
974        || slices[0].range_start_millis != query.range_start_millis
975        || slices
976            .last()
977            .is_none_or(|slice| slice.range_end_millis != query.range_end_millis)
978        || slices
979            .windows(2)
980            .any(|pair| pair[0].range_end_millis != pair[1].range_start_millis)
981        || slices
982            .iter()
983            .any(|slice| matches!(slice.kind, RollupRouteKindV1::Raw) != slice.rollup_coverage_digest.is_none())
984    {
985        return Err(LifecycleContractError::Violation(
986            "rollup routing did not cover the query exactly once",
987        ));
988    }
989    Ok(slices)
990}
991
992fn push_route_slice(
993    slices: &mut Vec<RollupRouteSliceV1>,
994    start: u64,
995    end: u64,
996    kind: RollupRouteKindV1,
997    rollup_coverage_digest: Option<RollupCoverageDigestV1>,
998) {
999    if start >= end {
1000        return;
1001    }
1002    if let Some(previous) = slices.last_mut()
1003        && previous.kind == kind
1004        && previous.range_end_millis == start
1005        && previous.rollup_coverage_digest == rollup_coverage_digest
1006    {
1007        previous.range_end_millis = end;
1008        return;
1009    }
1010    slices.push(RollupRouteSliceV1 {
1011        range_start_millis: start,
1012        range_end_millis: end,
1013        kind,
1014        rollup_coverage_digest,
1015    });
1016}
1017
1018pub fn encode_encrypted_object_key_ref_v1(value: &EncryptedObjectKeyRefV1) -> Result<Vec<u8>, LifecycleContractError> {
1019    encode_current(value, EncryptedObjectKeyRefV1::validate)
1020}
1021
1022pub fn decode_encrypted_object_key_ref_v1(bytes: &[u8]) -> Result<EncryptedObjectKeyRefV1, LifecycleContractError> {
1023    decode_current(bytes, EncryptedObjectKeyRefV1::validate)
1024}
1025
1026pub fn encode_index_external_key_binding_v1(
1027    value: &IndexExternalKeyBindingV1,
1028) -> Result<Vec<u8>, LifecycleContractError> {
1029    encode_current(value, IndexExternalKeyBindingV1::validate)
1030}
1031
1032pub fn decode_index_external_key_binding_v1(bytes: &[u8]) -> Result<IndexExternalKeyBindingV1, LifecycleContractError> {
1033    decode_current(bytes, IndexExternalKeyBindingV1::validate)
1034}
1035
1036pub fn encode_rollup_policy_v1(value: &RollupPolicyV1) -> Result<Vec<u8>, LifecycleContractError> {
1037    encode_current(value, RollupPolicyV1::validate)
1038}
1039
1040pub fn decode_rollup_policy_v1(bytes: &[u8]) -> Result<RollupPolicyV1, LifecycleContractError> {
1041    decode_current(bytes, RollupPolicyV1::validate)
1042}
1043
1044pub fn encode_rollup_output_v1(
1045    value: &RollupOutputV1,
1046    plan: &RollupWorkPlanV1,
1047) -> Result<Vec<u8>, LifecycleContractError> {
1048    value.validate(plan)?;
1049    let encoded = serde_json::to_vec(value).map_err(LifecycleContractError::serde)?;
1050    if encoded.len() > MAX_ROLLUP_OUTPUT_BYTES {
1051        return Err(LifecycleContractError::Budget {
1052            field: "rollup output bytes",
1053            observed: encoded.len() as u64,
1054            maximum: MAX_ROLLUP_OUTPUT_BYTES as u64,
1055        });
1056    }
1057    Ok(encoded)
1058}
1059
1060pub fn decode_rollup_output_v1(
1061    bytes: &[u8],
1062    plan: &RollupWorkPlanV1,
1063) -> Result<RollupOutputV1, LifecycleContractError> {
1064    if bytes.is_empty() || bytes.len() > MAX_ROLLUP_OUTPUT_BYTES {
1065        return Err(LifecycleContractError::Budget {
1066            field: "rollup output bytes",
1067            observed: bytes.len() as u64,
1068            maximum: MAX_ROLLUP_OUTPUT_BYTES as u64,
1069        });
1070    }
1071    let value: RollupOutputV1 = serde_json::from_slice(bytes).map_err(LifecycleContractError::serde)?;
1072    value.validate(plan)?;
1073    let canonical = serde_json::to_vec(&value).map_err(LifecycleContractError::serde)?;
1074    if canonical != bytes {
1075        return Err(LifecycleContractError::Violation(
1076            "rollup output is not the canonical current-format encoding",
1077        ));
1078    }
1079    Ok(value)
1080}
1081
1082pub fn encode_rollup_coverage_ledger_v1(value: &RollupCoverageLedgerV1) -> Result<Vec<u8>, LifecycleContractError> {
1083    encode_current(value, RollupCoverageLedgerV1::validate)
1084}
1085
1086pub fn decode_rollup_coverage_ledger_v1(bytes: &[u8]) -> Result<RollupCoverageLedgerV1, LifecycleContractError> {
1087    decode_current(bytes, RollupCoverageLedgerV1::validate)
1088}
1089
1090fn encode_current<T>(
1091    value: &T,
1092    validate: impl FnOnce(&T) -> Result<(), LifecycleContractError>,
1093) -> Result<Vec<u8>, LifecycleContractError>
1094where
1095    T: Serialize,
1096{
1097    validate(value)?;
1098    let bytes = serde_json::to_vec(value).map_err(LifecycleContractError::serde)?;
1099    if bytes.len() > MAX_LIFECYCLE_CONTRACT_BYTES {
1100        return Err(LifecycleContractError::Budget {
1101            field: "lifecycle contract bytes",
1102            observed: bytes.len() as u64,
1103            maximum: MAX_LIFECYCLE_CONTRACT_BYTES as u64,
1104        });
1105    }
1106    Ok(bytes)
1107}
1108
1109fn decode_current<T>(
1110    bytes: &[u8],
1111    validate: impl FnOnce(&T) -> Result<(), LifecycleContractError>,
1112) -> Result<T, LifecycleContractError>
1113where
1114    T: DeserializeOwned + Serialize,
1115{
1116    if bytes.len() > MAX_LIFECYCLE_CONTRACT_BYTES {
1117        return Err(LifecycleContractError::Budget {
1118            field: "lifecycle contract bytes",
1119            observed: bytes.len() as u64,
1120            maximum: MAX_LIFECYCLE_CONTRACT_BYTES as u64,
1121        });
1122    }
1123    let probe: serde_json::Value = serde_json::from_slice(bytes).map_err(LifecycleContractError::serde)?;
1124    let _version = probe
1125        .get("format_version")
1126        .ok_or(LifecycleContractError::MissingFormatVersion)?
1127        .as_u64()
1128        .and_then(|value| u16::try_from(value).ok())
1129        .ok_or_else(|| LifecycleContractError::Serde("format_version must be a u16".to_owned()))?;
1130    let value: T = serde_json::from_slice(bytes).map_err(LifecycleContractError::serde)?;
1131    validate(&value)?;
1132    if serde_json::to_vec(&value).map_err(LifecycleContractError::serde)? != bytes {
1133        return Err(LifecycleContractError::NonCanonical);
1134    }
1135    Ok(value)
1136}
1137
1138fn validate_format(version: u16) -> Result<(), LifecycleContractError> {
1139    if version == LIFECYCLE_COMPLETION_FORMAT_VERSION {
1140        Ok(())
1141    } else {
1142        Err(LifecycleContractError::UnsupportedFormat(version))
1143    }
1144}
1145
1146fn validate_external_key_format(version: u16) -> Result<(), LifecycleContractError> {
1147    if version == EXTERNAL_KEY_FORMAT_VERSION {
1148        Ok(())
1149    } else {
1150        Err(LifecycleContractError::UnsupportedFormat(version))
1151    }
1152}
1153
1154#[derive(Clone, Debug, Eq, PartialEq)]
1155pub enum LifecycleContractError {
1156    UnsupportedFormat(u16),
1157    MissingFormatVersion,
1158    NonCanonical,
1159    ZeroIdentity(&'static str),
1160    Budget {
1161        field: &'static str,
1162        observed: u64,
1163        maximum: u64,
1164    },
1165    Violation(&'static str),
1166    DetailRetiredByPolicy,
1167    Nested(String),
1168    Serde(String),
1169}
1170
1171impl LifecycleContractError {
1172    fn serde(error: serde_json::Error) -> Self {
1173        Self::Serde(error.to_string())
1174    }
1175}
1176
1177impl Display for LifecycleContractError {
1178    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
1179        match self {
1180            Self::UnsupportedFormat(version) => write!(formatter, "unsupported lifecycle format {version}"),
1181            Self::MissingFormatVersion => formatter.write_str("lifecycle format version is missing"),
1182            Self::NonCanonical => formatter.write_str("lifecycle bytes are not canonical current format"),
1183            Self::ZeroIdentity(name) => write!(formatter, "{name} cannot be zero"),
1184            Self::Budget {
1185                field,
1186                observed,
1187                maximum,
1188            } => write!(formatter, "{field} {observed} exceeds {maximum}"),
1189            Self::Violation(message) => formatter.write_str(message),
1190            Self::DetailRetiredByPolicy => {
1191                formatter.write_str("detail_retired_by_policy: raw detail is retired and this query is outside the declared rollup contract")
1192            }
1193            Self::Nested(message) => write!(formatter, "nested lifecycle authority refused input: {message}"),
1194            Self::Serde(message) => write!(formatter, "lifecycle codec refused input: {message}"),
1195        }
1196    }
1197}
1198
1199impl Error for LifecycleContractError {}
1200
1201#[cfg(test)]
1202mod tests {
1203    use super::*;
1204    use crate::{BackendAuthorityId, CollectionId, IndexId, ObjectIncarnation, ObjectKey};
1205
1206    fn scope() -> Result<CollectionIndexRef, Box<dyn Error>> {
1207        Ok(CollectionIndexRef::new(CollectionId::new(1)?, IndexId::new(1)?))
1208    }
1209
1210    fn physical(key: &str) -> Result<PhysicalObjectId, Box<dyn Error>> {
1211        Ok(PhysicalObjectId {
1212            backend_authority: BackendAuthorityId::new("backend-1")?,
1213            object_incarnation: ObjectIncarnation::new(1)?,
1214            object_key: ObjectKey::new(key)?,
1215        })
1216    }
1217
1218    fn binding() -> Result<IndexExternalKeyBindingV1, Box<dyn Error>> {
1219        let context = ExternalKeyContextV1 {
1220            format_version: EXTERNAL_KEY_FORMAT_VERSION,
1221            collection_index_ref: scope()?,
1222            content_key_id: ContentKeyIdV1::new(1)?,
1223            content_key_generation: 1,
1224            binding_id: ExternalKeyBindingIdV1::new(1)?,
1225            binding_generation: 1,
1226            access_generation: 1,
1227            object_context_digest: [8; 32],
1228        };
1229        Ok(IndexExternalKeyBindingV1 {
1230            format_version: EXTERNAL_KEY_FORMAT_VERSION,
1231            binding_id: context.binding_id,
1232            collection_index_ref: context.collection_index_ref,
1233            content_key_id: context.content_key_id,
1234            content_key_generation: context.content_key_generation,
1235            binding_generation: context.binding_generation,
1236            access_generation: context.access_generation,
1237            opaque_binding: vec![7; 64],
1238            context,
1239            context_digest: context.digest()?,
1240            created_at_revision: ClusterRevision::new(1),
1241        })
1242    }
1243
1244    #[test]
1245    fn encrypted_object_and_binding_are_exact_current_format() -> Result<(), Box<dyn Error>> {
1246        let physical_object = physical("encrypted/object-1")?;
1247        let content_key_id = ContentKeyIdV1::new(1)?;
1248        let object = EncryptedObjectKeyRefV1 {
1249            format_version: EXTERNAL_KEY_FORMAT_VERSION,
1250            nonce: object_nonce_v1(content_key_id, 1, &physical_object)?,
1251            authenticated_context_digest: object_authenticated_context_digest_v1(
1252                DocumentEncryptionAlgorithmV1::Aes256Gcm,
1253                content_key_id,
1254                1,
1255                &physical_object,
1256                128,
1257            )?,
1258            physical_object,
1259            algorithm: DocumentEncryptionAlgorithmV1::Aes256Gcm,
1260            content_key_id,
1261            content_key_generation: 1,
1262            ciphertext_bytes: 128,
1263        };
1264        let bytes = encode_encrypted_object_key_ref_v1(&object)?;
1265        assert_eq!(decode_encrypted_object_key_ref_v1(&bytes)?, object);
1266        let binding = binding()?;
1267        let bytes = encode_index_external_key_binding_v1(&binding)?;
1268        assert_eq!(decode_index_external_key_binding_v1(&bytes)?, binding);
1269        Ok(())
1270    }
1271
1272    #[test]
1273    fn rollup_route_is_exact_and_retired_raw_refuses_mismatch() -> Result<(), Box<dyn Error>> {
1274        let dimension = FieldPathDigestV1::new([1; 32])?;
1275        let policy = RollupPolicyV1 {
1276            format_version: LIFECYCLE_COMPLETION_FORMAT_VERSION,
1277            collection_index_ref: scope()?,
1278            policy_id: RollupPolicyIdV1::new(1)?,
1279            policy_generation: PolicyGeneration::new(1),
1280            detail_retirement_acknowledged: true,
1281            time_zone: RollupTimeZoneV1::Utc,
1282            timestamp_field_digest: FieldPathDigestV1::new([3; 32])?,
1283            bucket_width_millis: 60_000,
1284            bucket_alignment_millis: 0,
1285            dimensions: vec![dimension],
1286            measures: vec![RollupMeasureV1::Count],
1287        };
1288        let policy_bytes = encode_rollup_policy_v1(&policy)?;
1289        assert_eq!(decode_rollup_policy_v1(&policy_bytes)?, policy);
1290        let mut unacknowledged = policy.clone();
1291        unacknowledged.detail_retirement_acknowledged = false;
1292        assert!(matches!(
1293            unacknowledged.validate(),
1294            Err(LifecycleContractError::Violation(_))
1295        ));
1296        let root = RootSetRef {
1297            format_version: crate::ROOT_SET_REF_FORMAT_VERSION,
1298            root: physical("rollup/root")?,
1299            content_digest: [9; 32],
1300            item_count: 1,
1301            page_count: 1,
1302            schema_generations: vec![SchemaGeneration::new(1)],
1303        };
1304        let mut coverage = RollupCoverageV1 {
1305            format_version: LIFECYCLE_COMPLETION_FORMAT_VERSION,
1306            collection_index_ref: scope()?,
1307            policy: policy.clone(),
1308            schema_generation: SchemaGeneration::new(1),
1309            source_incarnation: None,
1310            source_cut: None,
1311            source_generations: vec![RollupSourceGenerationV1 {
1312                shard_id: ShardId::new(0),
1313                manifest_generation: ManifestGeneration::new(1),
1314                correction_through: SeqNo::new(10),
1315            }],
1316            range_start_millis: 60_000,
1317            range_end_millis: 180_000,
1318            late_data_watermark_millis: 180_000,
1319            output_root: root,
1320            raw_retirement_state: RawRetirementStateV1::RawPresent,
1321            routing_proof: None,
1322        };
1323        let mut invalid_output_root = coverage.clone();
1324        invalid_output_root.output_root.item_count = 2;
1325        assert!(matches!(
1326            invalid_output_root.validate(),
1327            Err(LifecycleContractError::Violation(_))
1328        ));
1329        coverage.raw_retirement_state = RawRetirementStateV1::RawRetired;
1330        coverage.routing_proof = Some(RollupRoutingProofV1 {
1331            query_contract_digest: rollup_query_contract_digest_v1()?,
1332            coverage_digest: coverage.coverage_digest()?,
1333            proven_at_revision: ClusterRevision::new(2),
1334        });
1335        let ledger = RollupCoverageLedgerV1 {
1336            format_version: LIFECYCLE_COMPLETION_FORMAT_VERSION,
1337            collection_index_ref: scope()?,
1338            revision: 2,
1339            coverages: vec![coverage],
1340        };
1341        let bytes = encode_rollup_coverage_ledger_v1(&ledger)?;
1342        assert_eq!(decode_rollup_coverage_ledger_v1(&bytes)?, ledger);
1343        let coverage_digest = ledger.coverages[0].coverage_digest()?;
1344        let query = RollupQueryShapeV1 {
1345            range_start_millis: 0,
1346            range_end_millis: 240_000,
1347            time_zone: RollupTimeZoneV1::Utc,
1348            bucket_width_millis: 60_000,
1349            bucket_alignment_millis: 0,
1350            dimensions: vec![dimension],
1351            measures: vec![RollupMeasureV1::Count],
1352        };
1353        assert_eq!(
1354            route_rollup_query_v1(&ledger, &query)?,
1355            vec![
1356                RollupRouteSliceV1 {
1357                    range_start_millis: 0,
1358                    range_end_millis: 60_000,
1359                    kind: RollupRouteKindV1::Raw,
1360                    rollup_coverage_digest: None,
1361                },
1362                RollupRouteSliceV1 {
1363                    range_start_millis: 60_000,
1364                    range_end_millis: 180_000,
1365                    kind: RollupRouteKindV1::Rollup,
1366                    rollup_coverage_digest: Some(coverage_digest),
1367                },
1368                RollupRouteSliceV1 {
1369                    range_start_millis: 180_000,
1370                    range_end_millis: 240_000,
1371                    kind: RollupRouteKindV1::Raw,
1372                    rollup_coverage_digest: None,
1373                },
1374            ]
1375        );
1376        let mismatch = RollupQueryShapeV1 {
1377            dimensions: Vec::new(),
1378            ..query
1379        };
1380        assert_eq!(
1381            route_rollup_query_v1(&ledger, &mismatch),
1382            Err(LifecycleContractError::DetailRetiredByPolicy)
1383        );
1384        let mut stale_proof = ledger.clone();
1385        stale_proof.coverages[0].routing_proof = Some(RollupRoutingProofV1 {
1386            query_contract_digest: RollupQueryContractDigestV1::new([5; 32])?,
1387            coverage_digest: stale_proof.coverages[0].coverage_digest()?,
1388            proven_at_revision: ClusterRevision::new(2),
1389        });
1390        assert!(matches!(
1391            stale_proof.validate(),
1392            Err(LifecycleContractError::Violation(_))
1393        ));
1394        let mut future_proof = ledger.clone();
1395        future_proof.coverages[0]
1396            .routing_proof
1397            .as_mut()
1398            .ok_or("routing proof missing")?
1399            .proven_at_revision = ClusterRevision::new(3);
1400        assert!(matches!(
1401            future_proof.validate(),
1402            Err(LifecycleContractError::Violation(_))
1403        ));
1404        Ok(())
1405    }
1406
1407    #[test]
1408    fn rollup_output_refuses_empty_or_miscounted_groups() -> Result<(), Box<dyn Error>> {
1409        let plan = RollupWorkPlanV1 {
1410            format_version: LIFECYCLE_COMPLETION_FORMAT_VERSION,
1411            policy: RollupPolicyV1 {
1412                format_version: LIFECYCLE_COMPLETION_FORMAT_VERSION,
1413                collection_index_ref: scope()?,
1414                policy_id: RollupPolicyIdV1::new(1)?,
1415                policy_generation: PolicyGeneration::new(1),
1416                detail_retirement_acknowledged: true,
1417                time_zone: RollupTimeZoneV1::Utc,
1418                timestamp_field_digest: FieldPathDigestV1::new([3; 32])?,
1419                bucket_width_millis: 1_000,
1420                bucket_alignment_millis: 0,
1421                dimensions: Vec::new(),
1422                measures: vec![RollupMeasureV1::Count],
1423            },
1424            schema_generation: SchemaGeneration::new(1),
1425            source_incarnation: None,
1426            source_cut: None,
1427            source_generations: vec![RollupSourceGenerationV1 {
1428                shard_id: ShardId::new(0),
1429                manifest_generation: ManifestGeneration::new(1),
1430                correction_through: SeqNo::new(2),
1431            }],
1432            range_start_millis: 0,
1433            range_end_millis: 1_000,
1434            late_data_watermark_millis: 1_000,
1435        };
1436        let valid = RollupOutputV1 {
1437            format_version: LIFECYCLE_COMPLETION_FORMAT_VERSION,
1438            work_plan_digest: plan.digest()?,
1439            rows: vec![RollupOutputRowV1 {
1440                bucket_start_millis: 0,
1441                dimensions: Vec::new(),
1442                measures: vec![RollupAggregateValueV1::Count(2)],
1443            }],
1444            input_documents: 2,
1445            input_bytes: 64,
1446        };
1447        encode_rollup_output_v1(&valid, &plan)?;
1448        let mut empty = valid.clone();
1449        empty.rows.clear();
1450        assert!(encode_rollup_output_v1(&empty, &plan).is_err());
1451        let mut miscounted = valid;
1452        miscounted.rows[0].measures[0] = RollupAggregateValueV1::Count(1);
1453        assert!(encode_rollup_output_v1(&miscounted, &plan).is_err());
1454        Ok(())
1455    }
1456
1457    #[test]
1458    fn adjacent_rollup_roots_remain_distinct_route_slices() -> Result<(), Box<dyn Error>> {
1459        let dimension = FieldPathDigestV1::new([1; 32])?;
1460        let policy = RollupPolicyV1 {
1461            format_version: LIFECYCLE_COMPLETION_FORMAT_VERSION,
1462            collection_index_ref: scope()?,
1463            policy_id: RollupPolicyIdV1::new(1)?,
1464            policy_generation: PolicyGeneration::new(1),
1465            detail_retirement_acknowledged: true,
1466            time_zone: RollupTimeZoneV1::Utc,
1467            timestamp_field_digest: FieldPathDigestV1::new([3; 32])?,
1468            bucket_width_millis: 60_000,
1469            bucket_alignment_millis: 0,
1470            dimensions: vec![dimension],
1471            measures: vec![RollupMeasureV1::Count],
1472        };
1473        let root = |name: &str| -> Result<RootSetRef, Box<dyn Error>> {
1474            Ok(RootSetRef {
1475                format_version: crate::ROOT_SET_REF_FORMAT_VERSION,
1476                root: physical(name)?,
1477                content_digest: if name.ends_with('1') { [8; 32] } else { [9; 32] },
1478                item_count: 1,
1479                page_count: 1,
1480                schema_generations: vec![SchemaGeneration::new(1)],
1481            })
1482        };
1483        let coverage = |start, end, output_root| -> Result<RollupCoverageV1, Box<dyn Error>> {
1484            Ok(RollupCoverageV1 {
1485                format_version: LIFECYCLE_COMPLETION_FORMAT_VERSION,
1486                collection_index_ref: scope()?,
1487                policy: policy.clone(),
1488                schema_generation: SchemaGeneration::new(1),
1489                source_incarnation: None,
1490                source_cut: None,
1491                source_generations: vec![RollupSourceGenerationV1 {
1492                    shard_id: ShardId::new(0),
1493                    manifest_generation: ManifestGeneration::new(1),
1494                    correction_through: SeqNo::new(10),
1495                }],
1496                range_start_millis: start,
1497                range_end_millis: end,
1498                late_data_watermark_millis: end,
1499                output_root,
1500                raw_retirement_state: RawRetirementStateV1::RawPresent,
1501                routing_proof: None,
1502            })
1503        };
1504        let first = coverage(60_000, 120_000, root("rollup/root-1")?)?;
1505        let second = coverage(120_000, 180_000, root("rollup/root-2")?)?;
1506        let first_digest = first.coverage_digest()?;
1507        let second_digest = second.coverage_digest()?;
1508        assert_ne!(first_digest, second_digest);
1509        let ledger = RollupCoverageLedgerV1 {
1510            format_version: LIFECYCLE_COMPLETION_FORMAT_VERSION,
1511            collection_index_ref: scope()?,
1512            revision: 1,
1513            coverages: vec![first, second],
1514        };
1515        let query = RollupQueryShapeV1 {
1516            range_start_millis: 60_000,
1517            range_end_millis: 180_000,
1518            time_zone: RollupTimeZoneV1::Utc,
1519            bucket_width_millis: 60_000,
1520            bucket_alignment_millis: 0,
1521            dimensions: vec![dimension],
1522            measures: vec![RollupMeasureV1::Count],
1523        };
1524        assert_eq!(
1525            route_rollup_query_v1(&ledger, &query)?,
1526            vec![
1527                RollupRouteSliceV1 {
1528                    range_start_millis: 60_000,
1529                    range_end_millis: 120_000,
1530                    kind: RollupRouteKindV1::Rollup,
1531                    rollup_coverage_digest: Some(first_digest),
1532                },
1533                RollupRouteSliceV1 {
1534                    range_start_millis: 120_000,
1535                    range_end_millis: 180_000,
1536                    kind: RollupRouteKindV1::Rollup,
1537                    rollup_coverage_digest: Some(second_digest),
1538                },
1539            ]
1540        );
1541        Ok(())
1542    }
1543
1544    #[test]
1545    fn codec_refuses_old_future_missing_unknown_duplicate_noncanonical_and_oversized() -> Result<(), Box<dyn Error>> {
1546        let bytes = encode_index_external_key_binding_v1(&binding()?)?;
1547        let mut old: serde_json::Value = serde_json::from_slice(&bytes)?;
1548        let old_version = EXTERNAL_KEY_FORMAT_VERSION - 1;
1549        old["format_version"] = serde_json::Value::from(old_version);
1550        assert_eq!(
1551            decode_index_external_key_binding_v1(&serde_json::to_vec(&old)?),
1552            Err(LifecycleContractError::UnsupportedFormat(old_version))
1553        );
1554        let mut future: serde_json::Value = serde_json::from_slice(&bytes)?;
1555        let future_version = EXTERNAL_KEY_FORMAT_VERSION + 1;
1556        future["format_version"] = serde_json::Value::from(future_version);
1557        assert_eq!(
1558            decode_index_external_key_binding_v1(&serde_json::to_vec(&future)?),
1559            Err(LifecycleContractError::UnsupportedFormat(future_version))
1560        );
1561        assert_eq!(
1562            decode_index_external_key_binding_v1(br#"{"binding_id":"1"}"#),
1563            Err(LifecycleContractError::MissingFormatVersion)
1564        );
1565        let mut unknown: serde_json::Value = serde_json::from_slice(&bytes)?;
1566        unknown["unknown_binding"] = serde_json::json!(true);
1567        assert!(matches!(
1568            decode_index_external_key_binding_v1(&serde_json::to_vec(&unknown)?),
1569            Err(LifecycleContractError::Serde(_))
1570        ));
1571        let version_prefix = format!("{{\"format_version\":{EXTERNAL_KEY_FORMAT_VERSION},");
1572        let duplicate_prefix = format!(
1573            "{{\"format_version\":{EXTERNAL_KEY_FORMAT_VERSION},\"format_version\":{EXTERNAL_KEY_FORMAT_VERSION},"
1574        );
1575        let duplicate = String::from_utf8(bytes.clone())?.replacen(&version_prefix, &duplicate_prefix, 1);
1576        assert!(matches!(
1577            decode_index_external_key_binding_v1(duplicate.as_bytes()),
1578            Err(LifecycleContractError::Serde(_))
1579        ));
1580        let mut whitespace = bytes;
1581        whitespace.push(b'\n');
1582        assert_eq!(
1583            decode_index_external_key_binding_v1(&whitespace),
1584            Err(LifecycleContractError::NonCanonical)
1585        );
1586        assert!(matches!(
1587            decode_index_external_key_binding_v1(&vec![b' '; MAX_LIFECYCLE_CONTRACT_BYTES + 1]),
1588            Err(LifecycleContractError::Budget { .. })
1589        ));
1590        let mut mismatched = binding()?;
1591        mismatched.binding_id = ExternalKeyBindingIdV1::new(2)?;
1592        assert!(matches!(
1593            mismatched.validate(),
1594            Err(LifecycleContractError::Violation(_))
1595        ));
1596        Ok(())
1597    }
1598
1599    #[test]
1600    fn serialized_binding_contains_only_opaque_non_plaintext_material() -> Result<(), Box<dyn Error>> {
1601        let value = serde_json::to_value(binding()?)?;
1602        assert!(value.get("opaque_binding").is_some());
1603        assert!(value.get("plaintext_key").is_none());
1604        assert!(value.get("content_key_bytes").is_none());
1605        assert_eq!(value.as_object().map(serde_json::Map::len), Some(11));
1606        Ok(())
1607    }
1608}