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