Skip to main content

lean_ctx_protocol/
seat_value.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Strict wire models for the independently negotiated Team seat/value v1 contract.
4
5use 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        // These frozen fixtures use ASCII keys and integers. Preserve their
782        // canonical bytes even when feature unification enables preserve_order.
783        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                    // Equivalent fractional precision must preserve inclusive
1000                    // key validity and fail closed at the revocation instant.
1001                    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}