1use 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
19pub const LIFECYCLE_COMPLETION_FORMAT_VERSION: u16 = 2;
21pub 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#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
174#[serde(rename_all = "snake_case")]
175pub enum DocumentEncryptionAlgorithmV1 {
176 Aes256Gcm,
177}
178
179#[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#[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#[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
338pub 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 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#[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#[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#[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#[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
702pub 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 pub rollup_coverage_digest: Option<RollupCoverageDigestV1>,
912}
913
914pub 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}