Skip to main content

lean_ctx_protocol/
team_context.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Strict wire models for the version-one Team shared-context control plane.
4
5use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
6use std::cmp::Ordering;
7
8use crate::ValidationError;
9
10const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
11
12fn deserialize_optional_non_null<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
13where
14    D: Deserializer<'de>,
15    T: Deserialize<'de>,
16{
17    T::deserialize(deserializer).map(Some)
18}
19
20fn valid_slug(value: &str) -> bool {
21    (2..=128).contains(&value.len())
22        && value.bytes().all(|byte| {
23            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
24        })
25        && value
26            .as_bytes()
27            .first()
28            .is_some_and(u8::is_ascii_alphanumeric)
29        && value
30            .as_bytes()
31            .last()
32            .is_some_and(u8::is_ascii_alphanumeric)
33}
34
35fn valid_typed(value: &str, prefix: &str, max: usize) -> bool {
36    let Some(tail) = value.strip_prefix(prefix) else {
37        return false;
38    };
39    !tail.is_empty()
40        && value.len() <= max
41        && tail
42            .as_bytes()
43            .first()
44            .is_some_and(u8::is_ascii_alphanumeric)
45        && tail.bytes().all(|byte| {
46            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
47        })
48}
49
50fn valid_digest(value: &str, prefix: &str) -> bool {
51    value.strip_prefix(prefix).is_some_and(|tail| {
52        tail.len() == 64
53            && tail
54                .bytes()
55                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
56    })
57}
58
59fn valid_base64url(value: &str, encoded_len: usize, final_chars: &str) -> bool {
60    value.len() == encoded_len
61        && value
62            .bytes()
63            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
64        && value
65            .as_bytes()
66            .last()
67            .is_some_and(|last| final_chars.as_bytes().contains(last))
68}
69
70fn valid_timestamp(value: &str) -> bool {
71    let bytes = value.as_bytes();
72    (20..=30).contains(&bytes.len())
73        && bytes.last() == Some(&b'Z')
74        && bytes.get(4) == Some(&b'-')
75        && bytes.get(7) == Some(&b'-')
76        && bytes.get(10) == Some(&b'T')
77        && bytes.get(13) == Some(&b':')
78        && bytes.get(16) == Some(&b':')
79        && bytes[..4]
80            .iter()
81            .chain(&bytes[5..7])
82            .chain(&bytes[8..10])
83            .chain(&bytes[11..13])
84            .chain(&bytes[14..16])
85            .chain(&bytes[17..19])
86            .all(u8::is_ascii_digit)
87        && (bytes.len() == 20
88            || (bytes.len() >= 22
89                && bytes.get(19) == Some(&b'.')
90                && bytes[20..bytes.len() - 1].iter().all(u8::is_ascii_digit)))
91        && valid_calendar_fields(bytes)
92}
93
94fn valid_calendar_fields(bytes: &[u8]) -> bool {
95    let number = |start: usize, end: usize| {
96        std::str::from_utf8(&bytes[start..end])
97            .ok()?
98            .parse::<u32>()
99            .ok()
100    };
101    let Some(year) = number(0, 4) else {
102        return false;
103    };
104    if year == 0 {
105        return false;
106    }
107    let Some(month) = number(5, 7) else {
108        return false;
109    };
110    let Some(day) = number(8, 10) else {
111        return false;
112    };
113    let Some(hour) = number(11, 13) else {
114        return false;
115    };
116    let Some(minute) = number(14, 16) else {
117        return false;
118    };
119    let Some(second) = number(17, 19) else {
120        return false;
121    };
122    let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
123    let days = match month {
124        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
125        4 | 6 | 9 | 11 => 30,
126        2 if leap => 29,
127        2 => 28,
128        _ => return false,
129    };
130    (1..=days).contains(&day) && hour <= 23 && minute <= 59 && second <= 59
131}
132
133/// Orders two validated timestamps by instant: seconds first, then the
134/// fractional digits compared as a fraction (missing digits count as zero).
135pub fn timestamp_cmp(left: &TeamTimestampV1, right: &TeamTimestampV1) -> Ordering {
136    let left = left.as_str().as_bytes();
137    let right = right.as_str().as_bytes();
138    match left[..19].cmp(&right[..19]) {
139        Ordering::Equal => {
140            let left_fraction = if left.len() == 20 {
141                &[][..]
142            } else {
143                &left[20..left.len() - 1]
144            };
145            let right_fraction = if right.len() == 20 {
146                &[][..]
147            } else {
148                &right[20..right.len() - 1]
149            };
150            (0..left_fraction.len().max(right_fraction.len()))
151                .map(|index| {
152                    left_fraction
153                        .get(index)
154                        .copied()
155                        .unwrap_or(b'0')
156                        .cmp(&right_fraction.get(index).copied().unwrap_or(b'0'))
157                })
158                .find(|ordering| *ordering != Ordering::Equal)
159                .unwrap_or(Ordering::Equal)
160        }
161        ordering => ordering,
162    }
163}
164
165macro_rules! wire_string {
166    ($name:ident, $validator:expr) => {
167        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
168        #[serde(transparent)]
169        pub struct $name(String);
170        impl $name {
171            pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
172                let value = value.into();
173                if ($validator)(&value) {
174                    Ok(Self(value))
175                } else {
176                    Err(ValidationError::new(concat!(
177                        stringify!($name),
178                        " is malformed"
179                    )))
180                }
181            }
182            pub fn as_str(&self) -> &str {
183                &self.0
184            }
185        }
186        impl<'de> Deserialize<'de> for $name {
187            fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
188                Self::new(String::deserialize(deserializer)?).map_err(D::Error::custom)
189            }
190        }
191    };
192}
193
194wire_string!(TeamId, valid_slug);
195wire_string!(MemberId, |value: &str| valid_typed(value, "member:", 127));
196wire_string!(RecordDigest, |value: &str| valid_digest(value, "sha256:"));
197wire_string!(ContentDigest, |value: &str| valid_digest(value, "sha256:"));
198wire_string!(CanonicalBytesDigest, |value: &str| valid_digest(
199    value, "sha256:"
200));
201wire_string!(PseudonymDigest, |value: &str| valid_digest(
202    value,
203    "hmac-sha256:"
204));
205wire_string!(NonceDigest, |value: &str| valid_digest(value, "sha256:"));
206wire_string!(TeamTimestampV1, valid_timestamp);
207wire_string!(IdempotencyKey, |value: &str| (16..=128)
208    .contains(&value.len())
209    && value.bytes().all(
210        |byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')
211    ));
212wire_string!(ReasonCode, |value: &str| (2..=64).contains(&value.len())
213    && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
214    && value.bytes().all(|byte| byte.is_ascii_lowercase()
215        || byte.is_ascii_digit()
216        || matches!(byte, b'.' | b'_' | b'-')));
217wire_string!(KeyId, |value: &str| valid_typed(value, "key:", 124));
218wire_string!(Ed25519PublicKey, |value: &str| valid_base64url(
219    value,
220    43,
221    "AEIMQUYcgkosw048"
222));
223wire_string!(Ed25519Signature, |value: &str| valid_base64url(
224    value, 86, "AQgw"
225));
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "snake_case")]
229pub enum TeamClassification {
230    Public,
231    Internal,
232    Confidential,
233    Restricted,
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum TeamRole {
239    Owner,
240    Admin,
241    Approver,
242    Member,
243    Viewer,
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "snake_case")]
248pub enum TenantLifecycleState {
249    Active,
250    Archived,
251    Deleted,
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum MemberState {
257    Active,
258    Suspended,
259    Revoked,
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "snake_case")]
264pub enum InviteState {
265    Pending,
266    Accepted,
267    Expired,
268    Revoked,
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
272#[serde(rename_all = "snake_case")]
273pub enum TeamAction {
274    #[serde(rename = "checkpoint.create")]
275    CheckpointCreate,
276    #[serde(rename = "conflict.resolve")]
277    ConflictResolve,
278    #[serde(rename = "context.delete")]
279    ContextDelete,
280    #[serde(rename = "context.export")]
281    ContextExport,
282    #[serde(rename = "context.read")]
283    ContextRead,
284    #[serde(rename = "context.redact")]
285    ContextRedact,
286    #[serde(rename = "context.submit")]
287    ContextSubmit,
288    #[serde(rename = "invite.consume")]
289    InviteConsume,
290    #[serde(rename = "invite.issue")]
291    InviteIssue,
292    #[serde(rename = "lease.grant")]
293    LeaseGrant,
294    #[serde(rename = "lease.revoke")]
295    LeaseRevoke,
296    #[serde(rename = "membership.change")]
297    MembershipChange,
298    #[serde(rename = "organization.create")]
299    OrganizationCreate,
300    #[serde(rename = "policy.change")]
301    PolicyChange,
302    #[serde(rename = "promotion.decide")]
303    PromotionDecide,
304    #[serde(rename = "promotion.request")]
305    PromotionRequest,
306    #[serde(rename = "provenance.read")]
307    ProvenanceRead,
308    #[serde(rename = "receipt.read")]
309    ReceiptRead,
310    #[serde(rename = "role.change")]
311    RoleChange,
312    #[serde(rename = "workspace.create")]
313    WorkspaceCreate,
314    #[serde(rename = "workspace.read")]
315    WorkspaceRead,
316}
317
318macro_rules! wire_enum {
319    ($name:ident { $($variant:ident),+ $(,)? }) => {
320        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
321        #[serde(rename_all = "snake_case")]
322        pub enum $name { $($variant),+ }
323    };
324}
325
326wire_enum!(WorkspaceRoleState {
327    Active,
328    Superseded,
329    Revoked
330});
331wire_enum!(LeaseState {
332    Active,
333    Released,
334    Revoked,
335    Expired
336});
337wire_enum!(ContextLifecycleState {
338    Active,
339    Redacted,
340    Deleted
341});
342wire_enum!(AuthorityState {
343    Personal,
344    TeamCandidate,
345    Authoritative,
346    Historical
347});
348wire_enum!(ApprovalState {
349    NotRequired,
350    Pending,
351    Approved,
352    Rejected
353});
354wire_enum!(ContextObjectType {
355    Decision,
356    Knowledge,
357    Gotcha,
358    Policy,
359    PerformanceProfile
360});
361wire_enum!(SourceKind {
362    Personal,
363    Workspace,
364    Import,
365    Agent,
366    System
367});
368wire_enum!(PromotionState {
369    Pending,
370    Approved,
371    Rejected,
372    Superseded,
373    Expired
374});
375wire_enum!(PromotionAuthorityState {
376    TeamCandidate,
377    Authoritative,
378    Historical
379});
380wire_enum!(AuthorityDecisionOutcome {
381    Approved,
382    Rejected,
383    Superseded,
384    Expired
385});
386wire_enum!(TeamPolicyKind {
387    SourceAllowlist,
388    PromotionAuthority,
389    Retention,
390    Export,
391    Telemetry,
392    Provider,
393    Classification
394});
395wire_enum!(TeamPolicyState {
396    Active,
397    Superseded,
398    Expired,
399    Revoked
400});
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
402pub enum SignedPayloadKind {
403    #[serde(rename = "authority-decision")]
404    AuthorityDecision,
405    #[serde(rename = "policy")]
406    Policy,
407    #[serde(rename = "team-receipt")]
408    TeamReceipt,
409    #[serde(rename = "checkpoint")]
410    Checkpoint,
411}
412wire_enum!(ConflictState {
413    Open,
414    Resolved,
415    Superseded
416});
417wire_enum!(ConflictResolutionPolicy {
418    ManualAuthority,
419    NewerValidAuthority,
420    ExplicitSupersession
421});
422wire_enum!(ConflictWinner { Left, Right });
423wire_enum!(ProvenanceObjectType {
424    Decision,
425    Knowledge,
426    Gotcha,
427    Policy,
428    PerformanceProfile,
429    Checkpoint,
430    Receipt
431});
432wire_enum!(TeamReceiptOutcome {
433    Accepted,
434    Rejected,
435    Conflict,
436    Expired,
437    Cancelled
438});
439wire_enum!(CheckpointState {
440    Draft,
441    Sealed,
442    Superseded,
443    Expired
444});
445
446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
447#[serde(deny_unknown_fields)]
448pub struct TeamScopeV1 {
449    pub organization_id: TeamId,
450    pub workspace_id: TeamId,
451    #[serde(
452        default,
453        deserialize_with = "deserialize_optional_non_null",
454        skip_serializing_if = "Option::is_none"
455    )]
456    pub project_id: Option<TeamId>,
457    #[serde(
458        default,
459        deserialize_with = "deserialize_optional_non_null",
460        skip_serializing_if = "Option::is_none"
461    )]
462    pub task_id: Option<TeamId>,
463    #[serde(
464        default,
465        deserialize_with = "deserialize_optional_non_null",
466        skip_serializing_if = "Option::is_none"
467    )]
468    pub plan_id: Option<TeamId>,
469}
470
471fn validate_common(
472    v: u32,
473    version: u64,
474    parent: &Option<RecordDigest>,
475) -> Result<(), ValidationError> {
476    if v != 1 {
477        return Err(ValidationError::new("unsupported team-context version"));
478    }
479    if version == 0 || version > MAX_SAFE_INTEGER {
480        return Err(ValidationError::new("version is outside 1..=2^53-1"));
481    }
482    if (version == 1) == parent.is_some() {
483        return Err(ValidationError::new("CAS lineage does not match version"));
484    }
485    Ok(())
486}
487
488macro_rules! strict_record {
489    ($public:ident, $raw:ident, { $($(#[$attr:meta])* $field:ident : $ty:ty),* $(,)? }, |$value:ident| $validate:block) => {
490        #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
491        pub struct $public { $($(#[$attr])* pub $field: $ty),* }
492        #[derive(Deserialize)]
493        #[serde(deny_unknown_fields)]
494        struct $raw { $($(#[$attr])* $field: $ty),* }
495        impl $public {
496            pub fn validate(&self) -> Result<(), ValidationError> {
497                let $value = self;
498                $validate
499                Ok(())
500            }
501        }
502        impl TryFrom<$raw> for $public {
503            type Error = ValidationError;
504            fn try_from(raw: $raw) -> Result<Self, Self::Error> {
505                let candidate = Self { $($field: raw.$field),* };
506                candidate.validate()?;
507                Ok(candidate)
508            }
509        }
510        impl<'de> Deserialize<'de> for $public {
511            fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
512                Self::try_from($raw::deserialize(deserializer)?).map_err(D::Error::custom)
513            }
514        }
515    };
516}
517
518strict_record!(OrganizationV1, OrganizationRaw, {
519    v: u32, organization_id: TeamId, name: String, created_by: MemberId,
520    created_at: TeamTimestampV1, classification: TeamClassification,
521    lifecycle_state: TenantLifecycleState,
522    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] deleted_at: Option<TeamTimestampV1>,
523    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] deletion_receipt_digest: Option<RecordDigest>, version: u64,
524    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>, idempotency_key: IdempotencyKey
525}, |value| {
526    validate_common(value.v, value.version, &value.parent_digest)?;
527    if value.name.is_empty() || value.name.chars().count() > 128 { return Err(ValidationError::new("organization name is outside 1..=128 characters")); }
528    let deleted = value.lifecycle_state == TenantLifecycleState::Deleted;
529    if deleted != (value.deleted_at.is_some() && value.deletion_receipt_digest.is_some()) || (!deleted && (value.deleted_at.is_some() || value.deletion_receipt_digest.is_some())) { return Err(ValidationError::new("organization deletion fields disagree with lifecycle")); }
530});
531
532strict_record!(WorkspaceV1, WorkspaceRaw, {
533    v: u32, organization_id: TeamId, workspace_id: TeamId, name: String,
534    created_by: MemberId, created_at: TeamTimestampV1, classification: TeamClassification,
535    lifecycle_state: TenantLifecycleState,
536    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] deleted_at: Option<TeamTimestampV1>,
537    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] deletion_receipt_digest: Option<RecordDigest>, version: u64,
538    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>, idempotency_key: IdempotencyKey
539}, |value| {
540    validate_common(value.v, value.version, &value.parent_digest)?;
541    if value.name.is_empty() || value.name.chars().count() > 128 { return Err(ValidationError::new("workspace name is outside 1..=128 characters")); }
542    let deleted = value.lifecycle_state == TenantLifecycleState::Deleted;
543    if deleted != (value.deleted_at.is_some() && value.deletion_receipt_digest.is_some()) || (!deleted && (value.deleted_at.is_some() || value.deletion_receipt_digest.is_some())) { return Err(ValidationError::new("workspace deletion fields disagree with lifecycle")); }
544});
545
546strict_record!(TeamMemberV1, TeamMemberRaw, {
547    v: u32, organization_id: TeamId, member_id: MemberId,
548    subject_digest: PseudonymDigest, state: MemberState, created_at: TeamTimestampV1,
549    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revoked_at: Option<TeamTimestampV1>,
550    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revoked_by: Option<MemberId>,
551    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revocation_reason_code: Option<ReasonCode>, version: u64,
552    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>, idempotency_key: IdempotencyKey
553}, |value| {
554    validate_common(value.v, value.version, &value.parent_digest)?;
555    let revoked = value.state == MemberState::Revoked;
556    let complete = value.revoked_at.is_some() && value.revoked_by.is_some() && value.revocation_reason_code.is_some();
557    if revoked != complete || (!revoked && (value.revoked_at.is_some() || value.revoked_by.is_some() || value.revocation_reason_code.is_some())) { return Err(ValidationError::new("member revocation fields disagree with state")); }
558});
559
560strict_record!(MembershipV1, MembershipRaw, {
561    v: u32, scope: TeamScopeV1, membership_id: TeamId, member_id: MemberId,
562    role: TeamRole, state: MemberState, version: u64, created_at: TeamTimestampV1,
563    created_by: MemberId, valid_from: TeamTimestampV1,
564    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] valid_until: Option<TeamTimestampV1>,
565    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revoked_at: Option<TeamTimestampV1>,
566    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revoked_by: Option<MemberId>,
567    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revocation_reason_code: Option<ReasonCode>,
568    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>,
569    idempotency_key: IdempotencyKey
570}, |value| {
571    validate_common(value.v, value.version, &value.parent_digest)?;
572    if value.valid_until.as_ref().is_some_and(|valid_until| {
573        timestamp_cmp(valid_until, &value.valid_from) != Ordering::Greater
574    }) {
575        return Err(ValidationError::new(
576            "membership valid_until must be after valid_from",
577        ));
578    }
579    let revoked = value.state == MemberState::Revoked;
580    let complete = value.revoked_at.is_some() && value.revoked_by.is_some() && value.revocation_reason_code.is_some();
581    if revoked != complete || (!revoked && (value.revoked_at.is_some() || value.revoked_by.is_some() || value.revocation_reason_code.is_some())) { return Err(ValidationError::new("membership revocation fields disagree with state")); }
582});
583
584strict_record!(InviteV1, InviteRaw, {
585    v: u32, scope: TeamScopeV1, invite_id: TeamId, recipient_digest: PseudonymDigest,
586    role: TeamRole, state: InviteState, created_by: MemberId, created_at: TeamTimestampV1,
587    expires_at: TeamTimestampV1,
588    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] accepted_by: Option<MemberId>,
589    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] accepted_at: Option<TeamTimestampV1>,
590    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] membership_digest: Option<RecordDigest>,
591    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revoked_by: Option<MemberId>,
592    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revoked_at: Option<TeamTimestampV1>,
593    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revocation_reason_code: Option<ReasonCode>,
594    nonce_digest: NonceDigest, version: u64,
595    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>,
596    idempotency_key: IdempotencyKey
597}, |value| {
598    validate_common(value.v, value.version, &value.parent_digest)?;
599    if timestamp_cmp(&value.expires_at, &value.created_at) != Ordering::Greater {
600        return Err(ValidationError::new("invite expires_at must be after created_at"));
601    }
602    let accepted = value.accepted_by.is_some() && value.accepted_at.is_some() && value.membership_digest.is_some();
603    if (value.state == InviteState::Accepted) != accepted || (value.state != InviteState::Accepted && (value.accepted_by.is_some() || value.accepted_at.is_some() || value.membership_digest.is_some())) { return Err(ValidationError::new("invite acceptance fields disagree with state")); }
604    let revoked = value.revoked_by.is_some() && value.revoked_at.is_some() && value.revocation_reason_code.is_some();
605    if (value.state == InviteState::Revoked) != revoked || (value.state != InviteState::Revoked && (value.revoked_by.is_some() || value.revoked_at.is_some() || value.revocation_reason_code.is_some())) { return Err(ValidationError::new("invite revocation fields disagree with state")); }
606});
607
608strict_record!(WorkspaceRoleV1, WorkspaceRoleRaw, {
609    v: u32, role_binding_id: TeamId, scope: TeamScopeV1, role: TeamRole,
610    allowed_actions: Vec<TeamAction>, state: WorkspaceRoleState, created_by: MemberId,
611    created_at: TeamTimestampV1, valid_from: TeamTimestampV1,
612    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] valid_until: Option<TeamTimestampV1>,
613    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revoked_by: Option<MemberId>,
614    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revoked_at: Option<TeamTimestampV1>,
615    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revocation_reason_code: Option<ReasonCode>,
616    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] superseded_by: Option<RecordDigest>,
617    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] policy_digest: Option<RecordDigest>,
618    version: u64,
619    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>,
620    idempotency_key: IdempotencyKey
621}, |value| {
622    validate_common(value.v, value.version, &value.parent_digest)?;
623    if !(1..=21).contains(&value.allowed_actions.len())
624        || value.allowed_actions.iter().enumerate().any(|(index, action)| value.allowed_actions[..index].contains(action))
625    {
626        return Err(ValidationError::new("allowed_actions must contain 1..=21 unique actions"));
627    }
628    let revoked = value.revoked_by.is_some() && value.revoked_at.is_some() && value.revocation_reason_code.is_some();
629    if (value.state == WorkspaceRoleState::Revoked) != revoked
630        || (value.state != WorkspaceRoleState::Revoked
631            && (value.revoked_by.is_some() || value.revoked_at.is_some() || value.revocation_reason_code.is_some()))
632    {
633        return Err(ValidationError::new("workspace-role revocation fields disagree with state"));
634    }
635    if (value.state == WorkspaceRoleState::Superseded) != value.superseded_by.is_some() {
636        return Err(ValidationError::new("workspace-role supersession field disagrees with state"));
637    }
638});
639
640strict_record!(LeaseV1, LeaseRaw, {
641    v: u32, lease_id: TeamId, scope: TeamScopeV1, member_id: MemberId,
642    membership_digest: RecordDigest, role_binding_digest: RecordDigest, role: TeamRole,
643    granted_by: MemberId, granted_at: TeamTimestampV1, expires_at: TeamTimestampV1,
644    state: LeaseState,
645    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] released_at: Option<TeamTimestampV1>,
646    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revoked_by: Option<MemberId>,
647    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revoked_at: Option<TeamTimestampV1>,
648    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revocation_reason_code: Option<ReasonCode>,
649    version: u64,
650    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>,
651    idempotency_key: IdempotencyKey
652}, |value| {
653    validate_common(value.v, value.version, &value.parent_digest)?;
654    let revoked = value.revoked_by.is_some() && value.revoked_at.is_some() && value.revocation_reason_code.is_some();
655    if (value.state == LeaseState::Revoked) != revoked
656        || (value.state != LeaseState::Revoked
657            && (value.revoked_by.is_some() || value.revoked_at.is_some() || value.revocation_reason_code.is_some()))
658    {
659        return Err(ValidationError::new("lease revocation fields disagree with state"));
660    }
661    if (value.state == LeaseState::Released) != value.released_at.is_some() {
662        return Err(ValidationError::new("lease release field disagrees with state"));
663    }
664});
665
666strict_record!(ContextObjectV1, ContextObjectRaw, {
667    v: u32, object_id: TeamId, object_type: ContextObjectType, scope: TeamScopeV1,
668    source: SourceKind, author: MemberId, created_at: TeamTimestampV1,
669    valid_from: TeamTimestampV1,
670    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] valid_until: Option<TeamTimestampV1>,
671    classification: TeamClassification, lifecycle_state: ContextLifecycleState,
672    authority_state: AuthorityState, approval_state: ApprovalState,
673    content_digest: ContentDigest,
674    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] provenance_digest: Option<RecordDigest>,
675    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] authority_decision_digest: Option<RecordDigest>,
676    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] supersedes: Option<RecordDigest>,
677    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] superseded_by: Option<RecordDigest>,
678    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] redacted_at: Option<TeamTimestampV1>,
679    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] redaction_receipt_digest: Option<RecordDigest>,
680    version: u64,
681    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>,
682    idempotency_key: IdempotencyKey
683}, |value| {
684    validate_common(value.v, value.version, &value.parent_digest)?;
685    if value.authority_state != AuthorityState::Personal && value.provenance_digest.is_none() {
686        return Err(ValidationError::new("non-personal context object requires provenance"));
687    }
688    let approved = value.approval_state == ApprovalState::Approved;
689    if approved != value.authority_decision_digest.is_some() {
690        return Err(ValidationError::new("authority decision disagrees with approval state"));
691    }
692    if value.authority_state == AuthorityState::Authoritative && !approved {
693        return Err(ValidationError::new("authoritative context object must be approved"));
694    }
695    if value.authority_state == AuthorityState::Personal && approved {
696        return Err(ValidationError::new("personal context object cannot be approved"));
697    }
698    if value.authority_state == AuthorityState::Historical
699        && value.superseded_by.is_none() && value.valid_until.is_none()
700    {
701        return Err(ValidationError::new("historical context object needs an end condition"));
702    }
703    let redaction_complete = value.redacted_at.is_some() && value.redaction_receipt_digest.is_some();
704    let inactive = value.lifecycle_state != ContextLifecycleState::Active;
705    if inactive != redaction_complete
706        || (!inactive && (value.redacted_at.is_some() || value.redaction_receipt_digest.is_some()))
707    {
708        return Err(ValidationError::new("redaction evidence disagrees with lifecycle state"));
709    }
710});
711
712strict_record!(ContextProvenanceV1, ContextProvenanceRaw, {
713    v: u32, provenance_id: TeamId, scope: TeamScopeV1, object_id: TeamId,
714    object_type: ProvenanceObjectType, object_digest: RecordDigest, source_kind: SourceKind,
715    source_digest: ContentDigest, author: MemberId, created_at: TeamTimestampV1,
716    classification: TeamClassification, authority_state: AuthorityState,
717    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] supersedes: Option<RecordDigest>,
718    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] superseded_by: Option<RecordDigest>,
719    valid_from: TeamTimestampV1,
720    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] valid_until: Option<TeamTimestampV1>,
721    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] authority_decision_digest: Option<RecordDigest>,
722    version: u64,
723    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>,
724    idempotency_key: IdempotencyKey
725}, |value| {
726    validate_common(value.v, value.version, &value.parent_digest)?;
727    if value.authority_state == AuthorityState::Authoritative
728        && value.authority_decision_digest.is_none()
729    {
730        return Err(ValidationError::new("authoritative provenance requires authority decision"));
731    }
732    if value.authority_state == AuthorityState::Historical
733        && value.superseded_by.is_none() && value.valid_until.is_none()
734    {
735        return Err(ValidationError::new("historical provenance needs an end condition"));
736    }
737});
738
739strict_record!(ContextConflictV1, ContextConflictRaw, {
740    v: u32, conflict_id: TeamId, scope: TeamScopeV1, left_object_id: TeamId,
741    left_digest: RecordDigest, right_object_id: TeamId, right_digest: RecordDigest,
742    detected_at: TeamTimestampV1, state: ConflictState,
743    resolution_policy: ConflictResolutionPolicy,
744    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] resolved_by: Option<MemberId>,
745    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] resolved_at: Option<TeamTimestampV1>,
746    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] winner: Option<ConflictWinner>,
747    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] reason_code: Option<ReasonCode>,
748    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] superseded_by: Option<RecordDigest>,
749    version: u64,
750    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>,
751    idempotency_key: IdempotencyKey
752}, |value| {
753    validate_common(value.v, value.version, &value.parent_digest)?;
754    let resolution_complete = value.resolved_by.is_some() && value.resolved_at.is_some()
755        && value.winner.is_some() && value.reason_code.is_some();
756    if (value.state == ConflictState::Resolved) != resolution_complete
757        || (value.state != ConflictState::Resolved
758            && (value.resolved_by.is_some() || value.resolved_at.is_some()
759                || value.winner.is_some() || value.reason_code.is_some()))
760    {
761        return Err(ValidationError::new("conflict resolution fields disagree with state"));
762    }
763    if (value.state == ConflictState::Superseded) != value.superseded_by.is_some() {
764        return Err(ValidationError::new("conflict supersession field disagrees with state"));
765    }
766});
767
768strict_record!(TeamPolicyV1, TeamPolicyRaw, {
769    v: u32, policy_id: TeamId, scope: TeamScopeV1, policy_kind: TeamPolicyKind,
770    rules_digest: ContentDigest, created_by: MemberId, created_at: TeamTimestampV1,
771    state: TeamPolicyState, valid_from: TeamTimestampV1,
772    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] valid_until: Option<TeamTimestampV1>,
773    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] supersedes: Option<RecordDigest>,
774    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] superseded_by: Option<RecordDigest>,
775    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revoked_by: Option<MemberId>,
776    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revoked_at: Option<TeamTimestampV1>,
777    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] revocation_reason_code: Option<ReasonCode>,
778    signature_envelope_digest: RecordDigest, version: u64,
779    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>,
780    idempotency_key: IdempotencyKey
781}, |value| {
782    validate_common(value.v, value.version, &value.parent_digest)?;
783    let revoked = value.revoked_by.is_some() && value.revoked_at.is_some() && value.revocation_reason_code.is_some();
784    if (value.state == TeamPolicyState::Revoked) != revoked
785        || (value.state != TeamPolicyState::Revoked
786            && (value.revoked_by.is_some() || value.revoked_at.is_some() || value.revocation_reason_code.is_some()))
787    {
788        return Err(ValidationError::new("policy revocation fields disagree with state"));
789    }
790    if value.state == TeamPolicyState::Superseded && value.superseded_by.is_none() {
791        return Err(ValidationError::new("superseded policy requires successor"));
792    }
793    if value.state == TeamPolicyState::Expired && value.valid_until.is_none() {
794        return Err(ValidationError::new("expired policy requires validity end"));
795    }
796});
797
798strict_record!(PromotionV1, PromotionRaw, {
799    v: u32, promotion_id: TeamId, scope: TeamScopeV1, object_id: TeamId,
800    object_digest: RecordDigest, from_authority: AuthorityState,
801    to_authority: PromotionAuthorityState, requested_by: MemberId,
802    requested_at: TeamTimestampV1, policy_digest: RecordDigest,
803    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] decided_by: Option<MemberId>,
804    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] decided_at: Option<TeamTimestampV1>,
805    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] decision_reason_code: Option<ReasonCode>,
806    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] decision_digest: Option<RecordDigest>,
807    state: PromotionState,
808    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] valid_from: Option<TeamTimestampV1>,
809    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] valid_until: Option<TeamTimestampV1>,
810    version: u64,
811    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>,
812    idempotency_key: IdempotencyKey
813}, |value| {
814    validate_common(value.v, value.version, &value.parent_digest)?;
815    let decided = value.decided_by.is_some() && value.decided_at.is_some()
816        && value.decision_reason_code.is_some() && value.decision_digest.is_some();
817    let terminal = matches!(value.state, PromotionState::Approved | PromotionState::Rejected);
818    if terminal != decided
819        || (!terminal && (value.decided_by.is_some() || value.decided_at.is_some()
820            || value.decision_reason_code.is_some() || value.decision_digest.is_some()))
821    {
822        return Err(ValidationError::new("promotion decision fields disagree with state"));
823    }
824});
825
826strict_record!(AuthorityDecisionV1, AuthorityDecisionRaw, {
827    v: u32, decision_id: TeamId, scope: TeamScopeV1, promotion_id: TeamId,
828    promotion_digest: RecordDigest, object_id: TeamId, object_digest: RecordDigest,
829    decision: AuthorityDecisionOutcome, decided_by: MemberId, decided_at: TeamTimestampV1,
830    reason_code: ReasonCode, policy_digest: RecordDigest,
831    signature_envelope_digest: RecordDigest, version: u64,
832    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>,
833    idempotency_key: IdempotencyKey
834}, |value| { validate_common(value.v, value.version, &value.parent_digest)?; });
835
836strict_record!(SignatureEnvelopeV1, SignatureEnvelopeRaw, {
837    v: u32, envelope_id: TeamId, scope: TeamScopeV1, alg: String, key_id: KeyId,
838    key_epoch: u64, public_key: Ed25519PublicKey, signed_payload_kind: SignedPayloadKind,
839    signed_payload_digest: RecordDigest, signature: Ed25519Signature,
840    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] signed_by: Option<MemberId>,
841    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] signed_by_digest: Option<PseudonymDigest>,
842    signed_at: TeamTimestampV1, version: u64,
843    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>,
844    idempotency_key: IdempotencyKey
845}, |value| {
846    validate_common(value.v, value.version, &value.parent_digest)?;
847    if value.alg != "ed25519" { return Err(ValidationError::new("unsupported signature algorithm")); }
848    if value.key_epoch == 0 || value.key_epoch > MAX_SAFE_INTEGER {
849        return Err(ValidationError::new("key epoch is outside 1..=2^53-1"));
850    }
851    let receipt = value.signed_payload_kind == SignedPayloadKind::TeamReceipt;
852    if receipt != value.signed_by_digest.is_some() || receipt == value.signed_by.is_some() {
853        return Err(ValidationError::new("signature signer identity disagrees with payload kind"));
854    }
855});
856
857strict_record!(TeamReceiptV1, TeamReceiptRaw, {
858    v: u32, receipt_id: TeamId, scope: TeamScopeV1, operation_kind: TeamAction,
859    actor_digest: PseudonymDigest, input_digest: CanonicalBytesDigest,
860    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] output_digest: Option<CanonicalBytesDigest>,
861    outcome: TeamReceiptOutcome,
862    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] reason_code: Option<ReasonCode>,
863    created_at: TeamTimestampV1, policy_digest: RecordDigest,
864    signature_envelope_digest: RecordDigest, version: u64,
865    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>,
866    idempotency_key: IdempotencyKey
867}, |value| {
868    validate_common(value.v, value.version, &value.parent_digest)?;
869    if value.outcome == TeamReceiptOutcome::Accepted && value.output_digest.is_none() {
870        return Err(ValidationError::new("accepted receipt requires output digest"));
871    }
872    if value.outcome != TeamReceiptOutcome::Accepted && value.reason_code.is_none() {
873        return Err(ValidationError::new("non-accepted receipt requires reason code"));
874    }
875});
876
877strict_record!(CheckpointV1, CheckpointRaw, {
878    v: u32, checkpoint_id: TeamId, scope: TeamScopeV1, created_by: MemberId,
879    created_at: TeamTimestampV1, classification: TeamClassification,
880    checkpoint_state: CheckpointState, object_digests: Vec<RecordDigest>,
881    receipt_digests: Vec<RecordDigest>,
882    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] sealed_at: Option<TeamTimestampV1>,
883    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] superseded_by: Option<RecordDigest>,
884    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] signature_envelope_digest: Option<RecordDigest>,
885    version: u64,
886    #[serde(default, deserialize_with = "deserialize_optional_non_null", skip_serializing_if = "Option::is_none")] parent_digest: Option<RecordDigest>,
887    idempotency_key: IdempotencyKey
888}, |value| {
889    validate_common(value.v, value.version, &value.parent_digest)?;
890    if value.object_digests.is_empty() || value.object_digests.len() > 4096 {
891        return Err(ValidationError::new("checkpoint object digests are outside 1..=4096"));
892    }
893    if value.receipt_digests.len() > 4096 {
894        return Err(ValidationError::new("checkpoint receipt digests exceed 4096"));
895    }
896    if !value.object_digests.windows(2).all(|pair| pair[0].as_str() < pair[1].as_str())
897        || !value.receipt_digests.windows(2).all(|pair| pair[0].as_str() < pair[1].as_str())
898    {
899        return Err(ValidationError::new("checkpoint digests must be unique and sorted"));
900    }
901    let sealed = matches!(
902        value.checkpoint_state,
903        CheckpointState::Sealed | CheckpointState::Superseded | CheckpointState::Expired
904    );
905    if sealed != value.sealed_at.is_some() {
906        return Err(ValidationError::new("checkpoint seal field disagrees with state"));
907    }
908    if (value.checkpoint_state == CheckpointState::Superseded) != value.superseded_by.is_some() {
909        return Err(ValidationError::new("checkpoint supersession field disagrees with state"));
910    }
911});
912
913#[cfg(test)]
914mod tests;