1use 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;