1use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
6
7use crate::{
8 Ed25519PublicKey, Ed25519Signature, IdempotencyKey, KeyId, MemberId, PseudonymDigest,
9 ReasonCode, RecordDigest, TeamId, TeamTimestampV1, ValidationError,
10};
11
12const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
13
14fn optional_non_null<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
15where
16 D: Deserializer<'de>,
17 T: Deserialize<'de>,
18{
19 T::deserialize(deserializer).map(Some)
20}
21
22fn valid_issuer(value: &str) -> bool {
23 value.strip_prefix("issuer:").is_some_and(|tail| {
24 !tail.is_empty()
25 && value.len() <= 127
26 && tail
27 .as_bytes()
28 .first()
29 .is_some_and(u8::is_ascii_alphanumeric)
30 && tail.bytes().all(|b| {
31 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
32 })
33 })
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
37#[serde(transparent)]
38pub struct IssuerId(String);
39
40impl IssuerId {
41 pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
42 let value = value.into();
43 if valid_issuer(&value) {
44 Ok(Self(value))
45 } else {
46 Err(ValidationError::new("IssuerId is malformed"))
47 }
48 }
49 pub fn as_str(&self) -> &str {
50 &self.0
51 }
52}
53
54impl<'de> Deserialize<'de> for IssuerId {
55 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
56 Self::new(String::deserialize(deserializer)?).map_err(D::Error::custom)
57 }
58}
59
60macro_rules! digest_alias {
61 ($name:ident) => {
62 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
63 #[serde(transparent)]
64 pub struct $name(pub RecordDigest);
65
66 impl $name {
67 pub fn as_str(&self) -> &str {
68 self.0.as_str()
69 }
70 }
71 };
72}
73
74digest_alias!(CatalogDigest);
75digest_alias!(PriceDigest);
76digest_alias!(MethodologyDigest);
77digest_alias!(EvidenceDigest);
78digest_alias!(AcceptedOutcomeDigest);
79
80#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
81#[serde(transparent)]
82pub struct BillingDigest(pub PseudonymDigest);
83
84impl BillingDigest {
85 pub fn as_str(&self) -> &str {
86 self.0.as_str()
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct SeatValueScopeV1 {
93 pub organization_id: TeamId,
94 pub workspace_id: TeamId,
95}
96
97fn before(a: &TeamTimestampV1, b: &TeamTimestampV1) -> bool {
98 crate::team_context::timestamp_cmp(a, b) == std::cmp::Ordering::Less
99}
100
101fn validate_cas(
102 v: u32,
103 version: u64,
104 parent: &Option<RecordDigest>,
105) -> Result<(), ValidationError> {
106 if v != 1 || version == 0 || version > MAX_SAFE_INTEGER || ((version == 1) == parent.is_some())
107 {
108 return Err(ValidationError::new(
109 "invalid seat/value version or CAS lineage",
110 ));
111 }
112 Ok(())
113}
114
115fn sorted_unique(values: &[RecordDigest]) -> bool {
116 values
117 .windows(2)
118 .all(|pair| pair[0].as_str() < pair[1].as_str())
119}
120
121fn sorted_unique_evidence(values: &[EvidenceDigest]) -> bool {
122 values
123 .windows(2)
124 .all(|pair| pair[0].as_str() < pair[1].as_str())
125}
126
127fn sorted_unique_outcomes(values: &[AcceptedOutcomeDigest]) -> bool {
128 values
129 .windows(2)
130 .all(|pair| pair[0].as_str() < pair[1].as_str())
131}
132
133macro_rules! strict_record {
134 ($public:ident, $raw:ident, { $($(#[$attr:meta])* $field:ident : $ty:ty),* $(,)? }, |$value:ident| $validate:block) => {
135 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
136 pub struct $public { $($(#[$attr])* pub $field: $ty),* }
137 #[derive(Deserialize)]
138 #[serde(deny_unknown_fields)]
139 struct $raw { $($(#[$attr])* $field: $ty),* }
140 impl $public {
141 pub fn validate(&self) -> Result<(), ValidationError> {
142 let $value = self; $validate Ok(())
143 }
144 }
145 impl TryFrom<$raw> for $public {
146 type Error = ValidationError;
147 fn try_from(raw: $raw) -> Result<Self, Self::Error> {
148 let value = Self { $($field: raw.$field),* }; value.validate()?; Ok(value)
149 }
150 }
151 impl<'de> Deserialize<'de> for $public {
152 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
153 Self::try_from($raw::deserialize(d)?).map_err(D::Error::custom)
154 }
155 }
156 };
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160pub enum SeatValueActionV1 {
161 #[serde(rename = "entitlement.issue")]
162 EntitlementIssue,
163 #[serde(rename = "entitlement.renew")]
164 EntitlementRenew,
165 #[serde(rename = "entitlement.revoke")]
166 EntitlementRevoke,
167 #[serde(rename = "seat.allocate")]
168 SeatAllocate,
169 #[serde(rename = "seat.release")]
170 SeatRelease,
171 #[serde(rename = "seat.revoke")]
172 SeatRevoke,
173 #[serde(rename = "seat.reconcile")]
174 SeatReconcile,
175 #[serde(rename = "value.aggregate.generate")]
176 ValueAggregateGenerate,
177 #[serde(rename = "value.aggregate.correct")]
178 ValueAggregateCorrect,
179 #[serde(rename = "value.dashboard.read")]
180 ValueDashboardRead,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "kebab-case")]
185pub enum SignedSeatValueKindV1 {
186 SeatEntitlement,
187 SeatLimitDecision,
188 SeatAllocation,
189 SeatUsage,
190 TeamValueAggregate,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(rename_all = "kebab-case")]
195pub enum SeatValueKeyPurposeV1 {
196 BillingEntitlement,
197 SeatAuthority,
198 ValueAttestation,
199}
200
201strict_record!(SeatValueSignatureEnvelopeV1, SeatValueSignatureEnvelopeRaw, {
202 v: u32, envelope_id: TeamId, organization_id: TeamId, alg: String, key_id: KeyId,
203 key_epoch: u64, key_purpose: SeatValueKeyPurposeV1, issuer_id: IssuerId,
204 payload_kind: SignedSeatValueKindV1, payload_digest: RecordDigest,
205 public_key: Ed25519PublicKey, signature: Ed25519Signature, signed_at: TeamTimestampV1,
206 idempotency_key: IdempotencyKey
207}, |value| {
208 if value.v != 1 || value.alg != "ed25519" || value.key_epoch == 0 || value.key_epoch > MAX_SAFE_INTEGER {
209 return Err(ValidationError::new("invalid seat/value signature envelope"));
210 }
211 let purpose = match value.payload_kind {
212 SignedSeatValueKindV1::SeatEntitlement => SeatValueKeyPurposeV1::BillingEntitlement,
213 SignedSeatValueKindV1::SeatLimitDecision | SignedSeatValueKindV1::SeatAllocation => SeatValueKeyPurposeV1::SeatAuthority,
214 SignedSeatValueKindV1::SeatUsage | SignedSeatValueKindV1::TeamValueAggregate => SeatValueKeyPurposeV1::ValueAttestation,
215 };
216 if value.key_purpose != purpose { return Err(ValidationError::new("signature purpose disagrees with payload kind")); }
217});
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(deny_unknown_fields)]
221pub struct TrustedSeatValueKeyV1 {
222 pub organization_id: TeamId,
223 pub issuer_id: IssuerId,
224 pub key_id: KeyId,
225 pub key_purpose: SeatValueKeyPurposeV1,
226 pub minimum_epoch: u64,
227 pub public_key: Ed25519PublicKey,
228 pub valid_from: TeamTimestampV1,
229 pub valid_until: TeamTimestampV1,
230 #[serde(
231 default,
232 deserialize_with = "optional_non_null",
233 skip_serializing_if = "Option::is_none"
234 )]
235 pub revoked_at: Option<TeamTimestampV1>,
236}
237
238impl SeatValueSignatureEnvelopeV1 {
239 pub fn validate_trust_binding(
240 &self,
241 trusted: &TrustedSeatValueKeyV1,
242 ) -> Result<(), ValidationError> {
243 if self.organization_id != trusted.organization_id
244 || self.issuer_id != trusted.issuer_id
245 || self.key_id != trusted.key_id
246 || self.key_purpose != trusted.key_purpose
247 || self.public_key != trusted.public_key
248 || self.key_epoch < trusted.minimum_epoch
249 || before(&self.signed_at, &trusted.valid_from)
250 || before(&trusted.valid_until, &self.signed_at)
251 || trusted
252 .revoked_at
253 .as_ref()
254 .is_some_and(|revoked| !before(&self.signed_at, revoked))
255 {
256 return Err(ValidationError::new(
257 "signature envelope is not bound to a currently trusted key",
258 ));
259 }
260 Ok(())
261 }
262
263 pub fn validate_record_binding(
264 &self,
265 record_organization_id: &TeamId,
266 record_kind: SignedSeatValueKindV1,
267 computed_payload_digest: &RecordDigest,
268 computed_envelope_digest: &RecordDigest,
269 referenced_envelope_digest: &RecordDigest,
270 ) -> Result<(), ValidationError> {
271 if &self.organization_id != record_organization_id
272 || self.payload_kind != record_kind
273 || &self.payload_digest != computed_payload_digest
274 || computed_envelope_digest != referenced_envelope_digest
275 {
276 return Err(ValidationError::new(
277 "signature envelope does not bind the referenced record",
278 ));
279 }
280 Ok(())
281 }
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(rename_all = "snake_case")]
286pub enum SeatPlanV1 {
287 Team,
288 Enterprise,
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(rename_all = "snake_case")]
293pub enum EntitlementSourceV1 {
294 Subscription,
295 OfflineEnterprise,
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub enum SeatEntitlementStateV1 {
300 NotYetEffective,
301 Active,
302 Grace,
303 Expired,
304 Revoked,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
309pub enum SeatLimitV1 {
310 Finite { value: u64 },
311 Unlimited,
312}
313
314impl SeatLimitV1 {
315 fn validate(&self) -> Result<(), ValidationError> {
316 if matches!(self, Self::Finite { value: 0 })
317 || matches!(self, Self::Finite { value } if *value > MAX_SAFE_INTEGER)
318 {
319 return Err(ValidationError::new(
320 "finite seat limit is outside 1..=2^53-1",
321 ));
322 }
323 Ok(())
324 }
325}
326
327strict_record!(SeatEntitlementV1, SeatEntitlementRaw, {
328 v: u32, entitlement_id: TeamId, organization_id: TeamId, plan: SeatPlanV1,
329 source: EntitlementSourceV1, billing_account_digest: BillingDigest,
330 seat_limit: SeatLimitV1, effective_at: TeamTimestampV1, expires_at: TeamTimestampV1,
331 grace_until: TeamTimestampV1, catalog_digest: CatalogDigest, issuer_id: IssuerId,
332 sequence: u64,
333 #[serde(default, deserialize_with="optional_non_null", skip_serializing_if="Option::is_none")] predecessor_digest: Option<RecordDigest>,
334 #[serde(default, deserialize_with="optional_non_null", skip_serializing_if="Option::is_none")] revoked_at: Option<TeamTimestampV1>,
335 #[serde(default, deserialize_with="optional_non_null", skip_serializing_if="Option::is_none")] reason_code: Option<ReasonCode>,
336 issued_at: TeamTimestampV1, signature_envelope_digest: RecordDigest, idempotency_key: IdempotencyKey
337}, |value| {
338 if value.v != 1 || value.sequence == 0 || value.sequence > MAX_SAFE_INTEGER { return Err(ValidationError::new("invalid entitlement version or sequence")); }
339 value.seat_limit.validate()?;
340 if (value.sequence == 1) == value.predecessor_digest.is_some() { return Err(ValidationError::new("entitlement predecessor disagrees with sequence")); }
341 if !before(&value.effective_at, &value.expires_at) || before(&value.grace_until, &value.expires_at) { return Err(ValidationError::new("invalid entitlement time window")); }
342 if value.revoked_at.is_some() != value.reason_code.is_some() { return Err(ValidationError::new("incomplete entitlement revocation")); }
343});
344
345impl SeatEntitlementV1 {
346 pub fn state_at(&self, at: &TeamTimestampV1) -> SeatEntitlementStateV1 {
347 if self
348 .revoked_at
349 .as_ref()
350 .is_some_and(|revoked| !before(at, revoked))
351 {
352 SeatEntitlementStateV1::Revoked
353 } else if before(at, &self.effective_at) {
354 SeatEntitlementStateV1::NotYetEffective
355 } else if before(at, &self.expires_at) {
356 SeatEntitlementStateV1::Active
357 } else if before(at, &self.grace_until) {
358 SeatEntitlementStateV1::Grace
359 } else {
360 SeatEntitlementStateV1::Expired
361 }
362 }
363
364 pub fn validate_successor(
365 &self,
366 previous: &Self,
367 previous_digest: &RecordDigest,
368 ) -> Result<(), ValidationError> {
369 if self.organization_id != previous.organization_id
370 || self.issuer_id != previous.issuer_id
371 || Some(self.sequence) != previous.sequence.checked_add(1)
372 || self.predecessor_digest.as_ref() != Some(previous_digest)
373 {
374 return Err(ValidationError::new(
375 "entitlement successor does not extend the authoritative head",
376 ));
377 }
378 Ok(())
379 }
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(deny_unknown_fields)]
384pub struct AllocationRequestV1 {
385 pub scope: SeatValueScopeV1,
386 pub member_id: MemberId,
387 pub membership_digest: RecordDigest,
388 pub request_nonce_digest: RecordDigest,
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(rename_all = "snake_case")]
393pub enum SeatDecisionOutcomeV1 {
394 Allowed,
395 Denied,
396}
397
398strict_record!(SeatLimitDecisionV1, SeatLimitDecisionRaw, {
399 v: u32, decision_id: TeamId, scope: SeatValueScopeV1, action: SeatValueActionV1,
400 entitlement_digest: RecordDigest, membership_digest: RecordDigest,
401 allocation_request: AllocationRequestV1, allocation_request_digest: RecordDigest,
402 allocated_before: u64, requested_delta: u64, effective_limit: u64,
403 outcome: SeatDecisionOutcomeV1, reason_code: ReasonCode, decided_at: TeamTimestampV1,
404 signature_envelope_digest: RecordDigest, idempotency_key: IdempotencyKey
405}, |value| {
406 if value.v != 1 || value.action != SeatValueActionV1::SeatAllocate || value.requested_delta == 0 { return Err(ValidationError::new("invalid seat-limit decision")); }
407 if [value.allocated_before, value.requested_delta, value.effective_limit].into_iter().any(|n| n > MAX_SAFE_INTEGER) { return Err(ValidationError::new("seat count exceeds 2^53-1")); }
408 let fits = value.allocated_before.checked_add(value.requested_delta).is_some_and(|n| n <= value.effective_limit);
409 if value.outcome == SeatDecisionOutcomeV1::Allowed && !fits { return Err(ValidationError::new("allowed decision exceeds capacity")); }
410});
411
412impl SeatLimitDecisionV1 {
413 pub fn validate_entitlement_binding(
414 &self,
415 entitlement: &SeatEntitlementV1,
416 computed_entitlement_digest: &RecordDigest,
417 ) -> Result<(), ValidationError> {
418 let state = entitlement.state_at(&self.decided_at);
419 let usable = matches!(
420 state,
421 SeatEntitlementStateV1::Active | SeatEntitlementStateV1::Grace
422 );
423 let limit_matches = match &entitlement.seat_limit {
424 SeatLimitV1::Finite { value } => self.effective_limit == *value,
425 SeatLimitV1::Unlimited => self.effective_limit == MAX_SAFE_INTEGER,
426 };
427 if self.entitlement_digest != *computed_entitlement_digest
428 || self.scope.organization_id != entitlement.organization_id
429 || !limit_matches
430 || (self.outcome == SeatDecisionOutcomeV1::Allowed && !usable)
431 {
432 return Err(ValidationError::new(
433 "seat decision is not authorized by the entitlement head",
434 ));
435 }
436 Ok(())
437 }
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
441#[serde(rename_all = "snake_case")]
442pub enum SeatAllocationStateV1 {
443 Active,
444 Released,
445 Revoked,
446 Expired,
447}
448
449strict_record!(SeatAllocationV1, SeatAllocationRaw, {
450 v: u32, allocation_id: TeamId, scope: SeatValueScopeV1, member_id: MemberId,
451 membership_digest: RecordDigest, entitlement_digest: RecordDigest,
452 limit_decision_digest: RecordDigest, state: SeatAllocationStateV1,
453 allocated_at: TeamTimestampV1, allocated_by: MemberId,
454 #[serde(default, deserialize_with="optional_non_null", skip_serializing_if="Option::is_none")] ended_at: Option<TeamTimestampV1>,
455 #[serde(default, deserialize_with="optional_non_null", skip_serializing_if="Option::is_none")] ended_by: Option<MemberId>,
456 #[serde(default, deserialize_with="optional_non_null", skip_serializing_if="Option::is_none")] reason_code: Option<ReasonCode>,
457 version: u64,
458 #[serde(default, deserialize_with="optional_non_null", skip_serializing_if="Option::is_none")] parent_digest: Option<RecordDigest>,
459 signature_envelope_digest: RecordDigest, idempotency_key: IdempotencyKey
460}, |value| {
461 validate_cas(value.v, value.version, &value.parent_digest)?;
462 let terminal = value.state != SeatAllocationStateV1::Active;
463 let complete = value.ended_at.is_some() && value.ended_by.is_some() && value.reason_code.is_some();
464 if terminal != complete { return Err(ValidationError::new("allocation terminal fields disagree with state")); }
465});
466
467impl SeatAllocationV1 {
468 pub fn validate_creation_binding(
469 &self,
470 decision: &SeatLimitDecisionV1,
471 computed_decision_digest: &RecordDigest,
472 ) -> Result<(), ValidationError> {
473 if self.version != 1
474 || self.state != SeatAllocationStateV1::Active
475 || decision.outcome != SeatDecisionOutcomeV1::Allowed
476 || self.limit_decision_digest != *computed_decision_digest
477 || self.scope != decision.scope
478 || self.member_id != decision.allocation_request.member_id
479 || self.membership_digest != decision.membership_digest
480 || self.entitlement_digest != decision.entitlement_digest
481 {
482 return Err(ValidationError::new(
483 "allocation creation does not match an allowed decision",
484 ));
485 }
486 Ok(())
487 }
488
489 pub fn validate_successor(
490 &self,
491 previous: &Self,
492 previous_digest: &RecordDigest,
493 ) -> Result<(), ValidationError> {
494 if self.allocation_id != previous.allocation_id
495 || self.scope != previous.scope
496 || self.member_id != previous.member_id
497 || self.entitlement_digest != previous.entitlement_digest
498 || Some(self.version) != previous.version.checked_add(1)
499 || self.parent_digest.as_ref() != Some(previous_digest)
500 || previous.state != SeatAllocationStateV1::Active
501 {
502 return Err(ValidationError::new(
503 "allocation successor violates CAS identity or lifecycle",
504 ));
505 }
506 Ok(())
507 }
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
511#[serde(rename_all = "snake_case")]
512pub enum SeatUsageSourceV1 {
513 Calculated,
514 Reconciled,
515}
516
517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
518#[serde(deny_unknown_fields)]
519pub struct SeatUsageWatermarkV1 {
520 pub event_sequence: u64,
521 pub late_arrival_cutoff: TeamTimestampV1,
522}
523
524strict_record!(SeatUsageV1, SeatUsageRaw, {
525 v: u32, usage_id: TeamId, organization_id: TeamId, entitlement_digest: RecordDigest,
526 period_start: TeamTimestampV1, period_end: TeamTimestampV1, allocated_seats: u64,
527 active_memberships: u64, peak_active_seats: u64, allocation_digests: Vec<RecordDigest>,
528 source: SeatUsageSourceV1, observed_at: TeamTimestampV1, watermark: SeatUsageWatermarkV1,
529 evidence_digests: Vec<EvidenceDigest>,
530 #[serde(default, deserialize_with="optional_non_null", skip_serializing_if="Option::is_none")] corrects_digest: Option<RecordDigest>,
531 signature_envelope_digest: RecordDigest, idempotency_key: IdempotencyKey
532}, |value| {
533 if value.v != 1 || !before(&value.period_start, &value.period_end) { return Err(ValidationError::new("invalid usage version or period")); }
534 if [value.allocated_seats, value.active_memberships, value.peak_active_seats, value.watermark.event_sequence].into_iter().any(|n| n > MAX_SAFE_INTEGER)
535 || value.allocation_digests.len() > 4096 || value.evidence_digests.len() > 4096
536 || !sorted_unique(&value.allocation_digests) || !sorted_unique_evidence(&value.evidence_digests) {
537 return Err(ValidationError::new("invalid usage bounds or ordering"));
538 }
539 if value.peak_active_seats < value.allocated_seats { return Err(ValidationError::new("peak seats below allocated seats")); }
540});
541
542#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
543pub struct ExactMoneyV1 {
544 pub currency: String,
545 pub coefficient: String,
546 pub scale: u8,
547}
548
549#[derive(Deserialize)]
550#[serde(deny_unknown_fields)]
551struct ExactMoneyRaw {
552 currency: String,
553 coefficient: String,
554 scale: u8,
555}
556
557impl ExactMoneyV1 {
558 pub fn validate(&self) -> Result<(), ValidationError> {
559 let digits = self
560 .coefficient
561 .strip_prefix('-')
562 .unwrap_or(&self.coefficient);
563 if self.currency.len() != 3
564 || !self.currency.bytes().all(|b| b.is_ascii_uppercase())
565 || self.scale > 18
566 || digits.is_empty()
567 || digits.len() > 39
568 || !digits.bytes().all(|b| b.is_ascii_digit())
569 || (digits.len() > 1 && digits.starts_with('0'))
570 || self.coefficient == "-0"
571 {
572 return Err(ValidationError::new("non-canonical exact money"));
573 }
574 Ok(())
575 }
576
577 fn amount(&self) -> Result<i128, ValidationError> {
578 self.coefficient
579 .parse()
580 .map_err(|_| ValidationError::new("money coefficient exceeds checked i128 arithmetic"))
581 }
582
583 pub fn round_half_even(&self, output_scale: u8) -> Result<Self, ValidationError> {
584 if output_scale > self.scale {
585 let factor = 10_i128
586 .checked_pow(u32::from(output_scale - self.scale))
587 .ok_or_else(|| ValidationError::new("money scale overflow"))?;
588 let coefficient = self
589 .amount()?
590 .checked_mul(factor)
591 .ok_or_else(|| ValidationError::new("money coefficient overflow"))?;
592 return Ok(Self {
593 currency: self.currency.clone(),
594 coefficient: coefficient.to_string(),
595 scale: output_scale,
596 });
597 }
598 let factor = 10_i128
599 .checked_pow(u32::from(self.scale - output_scale))
600 .ok_or_else(|| ValidationError::new("money scale overflow"))?;
601 let amount = self.amount()?;
602 let quotient = amount / factor;
603 let remainder = amount % factor;
604 let twice = remainder
605 .unsigned_abs()
606 .checked_mul(2)
607 .ok_or_else(|| ValidationError::new("money rounding overflow"))?;
608 let divisor = factor.unsigned_abs();
609 let away = twice > divisor || (twice == divisor && quotient % 2 != 0);
610 let rounded = if away {
611 quotient
612 .checked_add(amount.signum())
613 .ok_or_else(|| ValidationError::new("money rounding overflow"))?
614 } else {
615 quotient
616 };
617 Ok(Self {
618 currency: self.currency.clone(),
619 coefficient: rounded.to_string(),
620 scale: output_scale,
621 })
622 }
623}
624
625impl TryFrom<ExactMoneyRaw> for ExactMoneyV1 {
626 type Error = ValidationError;
627 fn try_from(raw: ExactMoneyRaw) -> Result<Self, Self::Error> {
628 let value = Self {
629 currency: raw.currency,
630 coefficient: raw.coefficient,
631 scale: raw.scale,
632 };
633 value.validate()?;
634 Ok(value)
635 }
636}
637
638impl<'de> Deserialize<'de> for ExactMoneyV1 {
639 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
640 Self::try_from(ExactMoneyRaw::deserialize(deserializer)?).map_err(D::Error::custom)
641 }
642}
643
644#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
645#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
646pub enum ValueFactV1 {
647 Measured {
648 metric: String,
649 value: ExactMoneyV1,
650 measurement_evidence_digest: EvidenceDigest,
651 },
652 Estimated {
653 metric: String,
654 value: ExactMoneyV1,
655 assumptions_digest: EvidenceDigest,
656 price_evidence_digest: PriceDigest,
657 },
658 Inferred {
659 metric: String,
660 value: ExactMoneyV1,
661 inference_method_digest: MethodologyDigest,
662 source_evidence_digests: Vec<EvidenceDigest>,
663 },
664 Calculated {
665 metric: String,
666 value: ExactMoneyV1,
667 formula_digest: MethodologyDigest,
668 input_evidence_digests: Vec<EvidenceDigest>,
669 },
670 Reconciled {
671 metric: String,
672 value: ExactMoneyV1,
673 reconciliation_evidence_digest: EvidenceDigest,
674 },
675 Unavailable {
676 metric: String,
677 reason_code: ReasonCode,
678 },
679}
680
681impl ValueFactV1 {
682 fn validate(&self, currency: &str) -> Result<(), ValidationError> {
683 let (metric, money, evidence) = match self {
684 Self::Measured { metric, value, .. }
685 | Self::Estimated { metric, value, .. }
686 | Self::Reconciled { metric, value, .. } => (metric, Some(value), None),
687 Self::Inferred {
688 metric,
689 value,
690 source_evidence_digests,
691 ..
692 } => (metric, Some(value), Some(source_evidence_digests)),
693 Self::Calculated {
694 metric,
695 value,
696 input_evidence_digests,
697 ..
698 } => (metric, Some(value), Some(input_evidence_digests)),
699 Self::Unavailable { metric, .. } => (metric, None, None),
700 };
701 const METRICS: &[&str] = &[
702 "total_cost",
703 "accepted_path_cost",
704 "waste_cost",
705 "total_tokens",
706 "accepted_path_tokens",
707 "waste_tokens",
708 "gross_value",
709 "net_value",
710 "tax",
711 "discount",
712 "credit",
713 "refund",
714 ];
715 if !METRICS.contains(&metric.as_str()) {
716 return Err(ValidationError::new("unknown value metric"));
717 }
718 if let Some(value) = money {
719 value.validate()?;
720 if value.currency != currency {
721 return Err(ValidationError::new("aggregate mixes currencies"));
722 }
723 }
724 if evidence.is_some_and(|v| v.is_empty() || v.len() > 64 || !sorted_unique_evidence(v)) {
725 return Err(ValidationError::new("invalid value evidence ordering"));
726 }
727 Ok(())
728 }
729
730 fn money(&self) -> Option<&ExactMoneyV1> {
731 match self {
732 Self::Measured { value, .. }
733 | Self::Estimated { value, .. }
734 | Self::Inferred { value, .. }
735 | Self::Calculated { value, .. }
736 | Self::Reconciled { value, .. } => Some(value),
737 Self::Unavailable { .. } => None,
738 }
739 }
740}
741
742strict_record!(TeamValueAggregateV1, TeamValueAggregateRaw, {
743 v: u32, aggregate_id: TeamId, scope: SeatValueScopeV1, period_start: TeamTimestampV1,
744 period_end: TeamTimestampV1, currency: String, methodology_digest: MethodologyDigest,
745 price_table_digest: PriceDigest, source_watermark: u64, input_digests: Vec<AcceptedOutcomeDigest>,
746 value_facts: Vec<ValueFactV1>, generated_at: TeamTimestampV1, version: u64,
747 #[serde(default, deserialize_with="optional_non_null", skip_serializing_if="Option::is_none")] parent_digest: Option<RecordDigest>,
748 signature_envelope_digest: RecordDigest, idempotency_key: IdempotencyKey
749}, |value| {
750 validate_cas(value.v, value.version, &value.parent_digest)?;
751 if !before(&value.period_start, &value.period_end) || value.currency.len() != 3 || !value.currency.bytes().all(|b| b.is_ascii_uppercase())
752 || value.source_watermark > MAX_SAFE_INTEGER || value.input_digests.is_empty() || value.input_digests.len() > 4096
753 || !sorted_unique_outcomes(&value.input_digests) || value.value_facts.is_empty() || value.value_facts.len() > 256 {
754 return Err(ValidationError::new("invalid aggregate bounds, period or ordering"));
755 }
756 let mut scale = None;
757 for fact in &value.value_facts {
758 fact.validate(&value.currency)?;
759 if let Some(money) = fact.money() {
760 if scale.is_some_and(|expected| expected != money.scale) {
761 return Err(ValidationError::new("aggregate mixes money scales"));
762 }
763 scale = Some(money.scale);
764 }
765 }
766});
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771 use serde_json::json;
772 use sha2::{Digest, Sha256};
773
774 fn json_digest(mut value: serde_json::Value, payload: bool) -> RecordDigest {
775 if payload {
776 value
777 .as_object_mut()
778 .unwrap()
779 .remove("signature_envelope_digest");
780 }
781 let bytes = serde_json::to_vec(&crate::entitlement::sort_json(value)).unwrap();
784 let hex = Sha256::digest(bytes)
785 .iter()
786 .map(|byte| format!("{byte:02x}"))
787 .collect::<String>();
788 RecordDigest::new(format!("sha256:{hex}")).unwrap()
789 }
790
791 #[test]
792 fn seat_timestamp_ordering_uses_canonical_team_instants() {
793 use std::cmp::Ordering::{Equal, Greater, Less};
794
795 for (left, right, ordering) in [
796 (
797 "2026-09-07T10:00:00Z",
798 "2026-09-07T10:00:00.000000000Z",
799 Equal,
800 ),
801 (
802 "2026-09-07T10:00:00.1Z",
803 "2026-09-07T10:00:00.100000000Z",
804 Equal,
805 ),
806 ("2026-09-07T10:00:00.09Z", "2026-09-07T10:00:00.1Z", Less),
807 (
808 "2026-09-07T10:00:00.000000001Z",
809 "2026-09-07T10:00:00.000000002Z",
810 Less,
811 ),
812 (
813 "2026-09-07T23:59:59.999999999Z",
814 "2026-09-08T00:00:00Z",
815 Less,
816 ),
817 (
818 "2026-09-07T10:00:01Z",
819 "2026-09-07T10:00:00.999999999Z",
820 Greater,
821 ),
822 ("2025-12-31T23:59:59Z", "2026-01-01T00:00:00Z", Less),
823 ("2024-02-29T23:59:59Z", "2024-03-01T00:00:00Z", Less),
824 ] {
825 let left = TeamTimestampV1::new(left).unwrap();
826 let right = TeamTimestampV1::new(right).unwrap();
827 assert_eq!(before(&left, &right), ordering == Less);
828 assert_eq!(before(&right, &left), ordering == Greater);
829 }
830 }
831
832 #[test]
833 fn entitlement_state_preserves_fractional_window_boundaries() {
834 let fixture: serde_json::Value = serde_json::from_str(include_str!(
835 "../../../../docs/contracts/team-seat-value-v1/fixtures/valid/positive.json"
836 ))
837 .unwrap();
838 let record = fixture["records"]
839 .as_array()
840 .unwrap()
841 .iter()
842 .find(|item| item["type"] == "seat-entitlement")
843 .unwrap()["record"]
844 .clone();
845 let mut entitlement: SeatEntitlementV1 = serde_json::from_value(record).unwrap();
846 let at = |fraction: &str| {
847 TeamTimestampV1::new(format!("2026-09-07T10:00:00.{fraction}Z")).unwrap()
848 };
849 entitlement.effective_at = at("1");
850 entitlement.expires_at = at("2");
851 entitlement.grace_until = at("3");
852 entitlement.validate().unwrap();
853 for (fraction, expected) in [
854 ("099999999", SeatEntitlementStateV1::NotYetEffective),
855 ("100000000", SeatEntitlementStateV1::Active),
856 ("199999999", SeatEntitlementStateV1::Active),
857 ("200000000", SeatEntitlementStateV1::Grace),
858 ("299999999", SeatEntitlementStateV1::Grace),
859 ("300000000", SeatEntitlementStateV1::Expired),
860 ] {
861 assert_eq!(entitlement.state_at(&at(fraction)), expected);
862 }
863 entitlement.revoked_at = Some(at("15"));
864 entitlement.reason_code = Some(ReasonCode::new("revoked").unwrap());
865 entitlement.validate().unwrap();
866 assert_eq!(
867 entitlement.state_at(&at("149999999")),
868 SeatEntitlementStateV1::Active
869 );
870 assert_eq!(
871 entitlement.state_at(&at("150000000")),
872 SeatEntitlementStateV1::Revoked
873 );
874 entitlement.grace_until = at("200000000");
875 entitlement.validate().unwrap();
876 entitlement.grace_until = at("199999999");
877 assert!(entitlement.validate().is_err());
878 }
879
880 #[test]
881 fn vocabularies_are_closed() {
882 assert!(serde_json::from_value::<SeatValueActionV1>(json!("seat.allocate")).is_ok());
883 assert!(serde_json::from_value::<SeatValueActionV1>(json!("membership.change")).is_err());
884 assert!(serde_json::from_value::<SignedSeatValueKindV1>(json!("seat-usage")).is_ok());
885 assert!(IssuerId::new("issuer:billing").is_ok());
886 assert!(IssuerId::new("issuer:Billing").is_err());
887 assert!(
888 serde_json::from_value::<SeatValueScopeV1>(json!({
889 "organization_id": "org-1",
890 "workspace_id": "workspace-1",
891 "project_id": "project-1"
892 }))
893 .is_err()
894 );
895 }
896
897 #[test]
898 fn exact_money_is_cross_language_safe() {
899 for coefficient in ["-0", "01", "", "1234567890123456789012345678901234567890"] {
900 let money = ExactMoneyV1 {
901 currency: "CHF".into(),
902 coefficient: coefficient.into(),
903 scale: 2,
904 };
905 assert!(money.validate().is_err(), "accepted {coefficient}");
906 let encoded = json!({"currency":"CHF", "coefficient":coefficient, "scale":2});
907 assert!(serde_json::from_value::<ExactMoneyV1>(encoded).is_err());
908 }
909 assert!(
910 ExactMoneyV1 {
911 currency: "CHF".into(),
912 coefficient: "-12500".into(),
913 scale: 2
914 }
915 .validate()
916 .is_ok()
917 );
918 for (coefficient, expected) in [
919 ("125", "12"),
920 ("135", "14"),
921 ("-125", "-12"),
922 ("-135", "-14"),
923 ] {
924 let rounded = ExactMoneyV1 {
925 currency: "CHF".into(),
926 coefficient: coefficient.into(),
927 scale: 2,
928 }
929 .round_half_even(1)
930 .unwrap();
931 assert_eq!(rounded.coefficient, expected);
932 }
933 }
934
935 #[test]
936 fn finite_seat_limit_is_positive_and_json_safe() {
937 assert!(SeatLimitV1::Finite { value: 1 }.validate().is_ok());
938 assert!(SeatLimitV1::Finite { value: 0 }.validate().is_err());
939 assert!(
940 SeatLimitV1::Finite {
941 value: MAX_SAFE_INTEGER + 1
942 }
943 .validate()
944 .is_err()
945 );
946 }
947
948 #[test]
949 fn canonical_fixture_matches_every_wire_type() {
950 let raw = include_str!(
951 "../../../../docs/contracts/team-seat-value-v1/fixtures/valid/positive.json"
952 )
953 .replace(
954 "sha256:REPLACE_ENTITLEMENT",
955 "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
956 )
957 .replace(
958 "sha256:REPLACE_REQUEST",
959 "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
960 )
961 .replace(
962 "sha256:REPLACE_DECISION",
963 "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
964 )
965 .replace(
966 "sha256:REPLACE_ALLOCATION",
967 "sha256:abababababababababababababababababababababababababababababababab",
968 )
969 .replace(
970 "sha256:REPLACE_USAGE",
971 "sha256:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
972 );
973 let fixture: serde_json::Value = serde_json::from_str(&raw).unwrap();
974 let mut primary_records = 0;
975 let mut envelopes = 0;
976 let mut receipts = 0;
977 let mut receipt_envelopes = 0;
978 for item in fixture["records"].as_array().unwrap() {
979 let record = item["record"].clone();
980 match item["type"].as_str().unwrap() {
981 "seat-value-signature-envelope" => {
982 envelopes += 1;
983 let envelope =
984 serde_json::from_value::<SeatValueSignatureEnvelopeV1>(record.clone())
985 .unwrap();
986 assert_eq!(serde_json::to_value(&envelope).unwrap(), record);
987 let mut trusted = TrustedSeatValueKeyV1 {
988 organization_id: envelope.organization_id.clone(),
989 issuer_id: envelope.issuer_id.clone(),
990 key_id: envelope.key_id.clone(),
991 key_purpose: envelope.key_purpose,
992 minimum_epoch: envelope.key_epoch,
993 public_key: envelope.public_key.clone(),
994 valid_from: envelope.signed_at.clone(),
995 valid_until: envelope.signed_at.clone(),
996 revoked_at: None,
997 };
998 envelope.validate_trust_binding(&trusted).unwrap();
999 let mut timed_envelope = envelope.clone();
1002 timed_envelope.signed_at =
1003 TeamTimestampV1::new("2026-09-07T10:00:00.100000000Z").unwrap();
1004 let mut timed_trusted = trusted.clone();
1005 timed_trusted.valid_from =
1006 TeamTimestampV1::new("2026-09-07T10:00:00.1Z").unwrap();
1007 timed_trusted.valid_until = timed_trusted.valid_from.clone();
1008 timed_envelope
1009 .validate_trust_binding(&timed_trusted)
1010 .unwrap();
1011 timed_trusted.revoked_at = Some(timed_trusted.valid_from.clone());
1012 assert!(
1013 timed_envelope
1014 .validate_trust_binding(&timed_trusted)
1015 .is_err()
1016 );
1017 timed_trusted.revoked_at = None;
1018 timed_trusted.valid_from =
1019 TeamTimestampV1::new("2026-09-07T10:00:00.100000001Z").unwrap();
1020 assert!(
1021 timed_envelope
1022 .validate_trust_binding(&timed_trusted)
1023 .is_err()
1024 );
1025 timed_trusted.valid_from = timed_envelope.signed_at.clone();
1026 timed_trusted.valid_until =
1027 TeamTimestampV1::new("2026-09-07T10:00:00.099999999Z").unwrap();
1028 assert!(
1029 timed_envelope
1030 .validate_trust_binding(&timed_trusted)
1031 .is_err()
1032 );
1033 trusted.minimum_epoch = envelope.key_epoch + 1;
1034 assert!(envelope.validate_trust_binding(&trusted).is_err());
1035 }
1036 "seat-entitlement" => {
1037 primary_records += 1;
1038 let value =
1039 serde_json::from_value::<SeatEntitlementV1>(record.clone()).unwrap();
1040 assert_eq!(serde_json::to_value(value).unwrap(), record);
1041 }
1042 "seat-limit-decision" => {
1043 primary_records += 1;
1044 let value =
1045 serde_json::from_value::<SeatLimitDecisionV1>(record.clone()).unwrap();
1046 assert_eq!(serde_json::to_value(value).unwrap(), record);
1047 }
1048 "seat-allocation" => {
1049 primary_records += 1;
1050 let value = serde_json::from_value::<SeatAllocationV1>(record.clone()).unwrap();
1051 assert_eq!(serde_json::to_value(value).unwrap(), record);
1052 }
1053 "seat-usage" => {
1054 primary_records += 1;
1055 let value = serde_json::from_value::<SeatUsageV1>(record.clone()).unwrap();
1056 assert_eq!(serde_json::to_value(value).unwrap(), record);
1057 }
1058 "team-value-aggregate" => {
1059 primary_records += 1;
1060 let value =
1061 serde_json::from_value::<TeamValueAggregateV1>(record.clone()).unwrap();
1062 assert_eq!(serde_json::to_value(value).unwrap(), record);
1063 let mut mixed_scale = record;
1064 let facts = mixed_scale["value_facts"].as_array_mut().unwrap();
1065 let mut valued = facts
1066 .iter_mut()
1067 .filter(|fact| fact.get("value").is_some())
1068 .take(2)
1069 .collect::<Vec<_>>();
1070 if valued.len() == 2 {
1071 valued[1]["value"]["scale"] = json!(3);
1072 assert!(
1073 serde_json::from_value::<TeamValueAggregateV1>(mixed_scale).is_err()
1074 );
1075 }
1076 }
1077 "team-seat-receipt" => receipts += 1,
1078 "team-seat-receipt-signature-envelope" => receipt_envelopes += 1,
1079 other => panic!("unknown fixture record {other}"),
1080 }
1081 }
1082 assert_eq!(
1083 (primary_records, envelopes, receipts, receipt_envelopes),
1084 (5, 5, 5, 5)
1085 );
1086 }
1087
1088 #[test]
1089 fn canonical_fixture_cross_record_bindings_fail_closed() {
1090 let fixture: serde_json::Value = serde_json::from_str(include_str!(
1091 "../../../../docs/contracts/team-seat-value-v1/fixtures/valid/positive.json"
1092 ))
1093 .unwrap();
1094 let record = |kind: &str| {
1095 fixture["records"]
1096 .as_array()
1097 .unwrap()
1098 .iter()
1099 .find(|item| item["type"] == kind)
1100 .unwrap()["record"]
1101 .clone()
1102 };
1103 let entitlement: SeatEntitlementV1 =
1104 serde_json::from_value(record("seat-entitlement")).unwrap();
1105 let entitlement_digest = json_digest(record("seat-entitlement"), true);
1106 let decision_json = record("seat-limit-decision");
1107 let decision: SeatLimitDecisionV1 = serde_json::from_value(decision_json.clone()).unwrap();
1108 decision
1109 .validate_entitlement_binding(&entitlement, &entitlement_digest)
1110 .unwrap();
1111 let mut wrong_limit = decision_json.clone();
1112 wrong_limit["effective_limit"] = json!(99);
1113 let wrong_limit: SeatLimitDecisionV1 = serde_json::from_value(wrong_limit).unwrap();
1114 assert!(
1115 wrong_limit
1116 .validate_entitlement_binding(&entitlement, &entitlement_digest)
1117 .is_err()
1118 );
1119
1120 let allocation_json = record("seat-allocation");
1121 let allocation: SeatAllocationV1 = serde_json::from_value(allocation_json.clone()).unwrap();
1122 let decision_digest = json_digest(decision_json.clone(), true);
1123 allocation
1124 .validate_creation_binding(&decision, &decision_digest)
1125 .unwrap();
1126 let mut wrong_member = allocation_json;
1127 wrong_member["member_id"] = json!("member:bob");
1128 let wrong_member: SeatAllocationV1 = serde_json::from_value(wrong_member).unwrap();
1129 assert!(
1130 wrong_member
1131 .validate_creation_binding(&decision, &decision_digest)
1132 .is_err()
1133 );
1134 }
1135
1136 #[test]
1137 fn canonical_fixture_envelopes_bind_actual_jcs_digests() {
1138 let fixture: serde_json::Value = serde_json::from_str(include_str!(
1139 "../../../../docs/contracts/team-seat-value-v1/fixtures/valid/positive.json"
1140 ))
1141 .unwrap();
1142 let records = fixture["records"].as_array().unwrap();
1143 for item in records.iter().filter(|item| {
1144 matches!(
1145 item["type"].as_str(),
1146 Some(
1147 "seat-entitlement"
1148 | "seat-limit-decision"
1149 | "seat-allocation"
1150 | "seat-usage"
1151 | "team-value-aggregate"
1152 )
1153 )
1154 }) {
1155 let record = item["record"].clone();
1156 let kind: SignedSeatValueKindV1 = serde_json::from_value(item["type"].clone()).unwrap();
1157 let payload = json_digest(record.clone(), true);
1158 let envelope_item = records
1159 .iter()
1160 .find(|candidate| {
1161 candidate["record"]["payload_digest"] == serde_json::to_value(&payload).unwrap()
1162 })
1163 .unwrap();
1164 let envelope_json = envelope_item["record"].clone();
1165 let envelope: SeatValueSignatureEnvelopeV1 =
1166 serde_json::from_value(envelope_json.clone()).unwrap();
1167 let envelope_digest = json_digest(envelope_json, false);
1168 let referenced =
1169 serde_json::from_value::<RecordDigest>(record["signature_envelope_digest"].clone())
1170 .unwrap();
1171 let organization = if let Some(value) = record.get("organization_id") {
1172 serde_json::from_value(value.clone()).unwrap()
1173 } else {
1174 serde_json::from_value(record["scope"]["organization_id"].clone()).unwrap()
1175 };
1176 envelope
1177 .validate_record_binding(
1178 &organization,
1179 kind,
1180 &payload,
1181 &envelope_digest,
1182 &referenced,
1183 )
1184 .unwrap();
1185 let tampered = RecordDigest::new(format!("sha256:{}", "0".repeat(64))).unwrap();
1186 assert!(
1187 envelope
1188 .validate_record_binding(
1189 &organization,
1190 kind,
1191 &tampered,
1192 &envelope_digest,
1193 &referenced,
1194 )
1195 .is_err()
1196 );
1197 }
1198 }
1199
1200 #[test]
1201 fn materialized_successors_enforce_lineage_and_cas() {
1202 let fixture: serde_json::Value = serde_json::from_str(include_str!(
1203 "../../../../docs/contracts/team-seat-value-v1/fixtures/cross-record/histories.json"
1204 ))
1205 .unwrap();
1206 let history = |id: &str| {
1207 fixture["histories"]
1208 .as_array()
1209 .unwrap()
1210 .iter()
1211 .find(|history| history["id"] == id)
1212 .unwrap()
1213 };
1214
1215 let entitlement_records = history("entitlement-successor-revoked-valid")["records"]
1216 .as_array()
1217 .unwrap();
1218 let first: SeatEntitlementV1 =
1219 serde_json::from_value(entitlement_records[0].clone()).unwrap();
1220 let second: SeatEntitlementV1 =
1221 serde_json::from_value(entitlement_records[1].clone()).unwrap();
1222 let first_digest = json_digest(entitlement_records[0].clone(), true);
1223 second.validate_successor(&first, &first_digest).unwrap();
1224 let stale = RecordDigest::new(format!("sha256:{}", "0".repeat(64))).unwrap();
1225 assert!(second.validate_successor(&first, &stale).is_err());
1226
1227 let allocation_records = history("allocation-cas-release-valid")["records"]
1228 .as_array()
1229 .unwrap();
1230 let allocated: SeatAllocationV1 =
1231 serde_json::from_value(allocation_records[0].clone()).unwrap();
1232 let released: SeatAllocationV1 =
1233 serde_json::from_value(allocation_records[1].clone()).unwrap();
1234 let allocated_digest = json_digest(allocation_records[0].clone(), true);
1235 released
1236 .validate_successor(&allocated, &allocated_digest)
1237 .unwrap();
1238 assert!(released.validate_successor(&allocated, &stale).is_err());
1239 let mut wrong_id = allocation_records[1].clone();
1240 wrong_id["allocation_id"] = json!("allocation-other");
1241 let wrong_id: SeatAllocationV1 = serde_json::from_value(wrong_id).unwrap();
1242 assert!(
1243 wrong_id
1244 .validate_successor(&allocated, &allocated_digest)
1245 .is_err()
1246 );
1247 }
1248}