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