1use std::collections::{BTreeMap, BTreeSet};
10
11use base64::engine::general_purpose::URL_SAFE_NO_PAD;
12use base64::Engine as _;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use sha2::Sha256;
16use sha3::{Digest, Sha3_256};
17
18use crate::error::{Result, SdkError};
19use crate::identity::{verify_envelope, AgentId, Envelope, Event, MAX_SAFE_NONCE};
20
21pub const PROTOCOL: &str = "agent-discourse/1.0";
22
23pub mod event_type {
25 pub const ROOM_CREATE: &str = "room.create";
26 pub const ROOM_UPDATE: &str = "room.update";
27 pub const ROOM_JOIN: &str = "room.join";
28 pub const ROOM_JOIN_REVIEW: &str = "room.join.review";
29 pub const ROOM_LEAVE: &str = "room.leave";
30 pub const ROOM_MEMBER_ROLE_UPDATE: &str = "room.member.role.update";
31 pub const ROOM_MEMBER_REMOVE: &str = "room.member.remove";
32 pub const ROOM_CLOSE: &str = "room.close";
33 pub const ROOM_CANCEL: &str = "room.cancel";
34 pub const TYPE_DEFINE: &str = "type.define";
35 pub const MESSAGE_CREATE: &str = "message.create";
36}
37
38pub const BUILTIN_EVENT_TYPES: [&str; 11] = [
39 event_type::ROOM_CREATE,
40 event_type::ROOM_UPDATE,
41 event_type::ROOM_JOIN,
42 event_type::ROOM_JOIN_REVIEW,
43 event_type::ROOM_LEAVE,
44 event_type::ROOM_MEMBER_ROLE_UPDATE,
45 event_type::ROOM_MEMBER_REMOVE,
46 event_type::ROOM_CLOSE,
47 event_type::ROOM_CANCEL,
48 event_type::TYPE_DEFINE,
49 event_type::MESSAGE_CREATE,
50];
51
52pub const MEMBERSHIP_EVENT_TYPES: [&str; 5] = [
56 event_type::ROOM_JOIN,
57 event_type::ROOM_JOIN_REVIEW,
58 event_type::ROOM_LEAVE,
59 event_type::ROOM_MEMBER_ROLE_UPDATE,
60 event_type::ROOM_MEMBER_REMOVE,
61];
62
63pub const DISCOURSE_ERROR_CODES: [&str; 29] = [
65 "invalid_event",
66 "invalid_event_hash",
67 "invalid_signature",
68 "invalid_actor",
69 "timestamp_out_of_window",
70 "nonce_not_greater",
71 "room_not_found",
72 "room_not_active",
73 "room_ended",
74 "permission_denied",
75 "approval_required",
76 "join_request_not_found",
77 "join_request_not_approved",
78 "join_request_role_mismatch",
79 "join_request_expired",
80 "member_banned",
81 "role_not_allowed",
82 "max_speakers_exceeded",
83 "membership_required",
84 "invalid_token",
85 "room_head_mismatch",
86 "base_record_mismatch",
87 "agent_status_not_found",
88 "rate_limited",
89 "payload_too_large",
90 "type_not_defined",
91 "type_disabled",
92 "payload_schema_violation",
93 "pack_unavailable",
94];
95
96pub const MAX_MENTIONS: usize = 32;
98
99pub const RESERVED_TYPE_PREFIXES: [&str; 2] = ["room.", "type."];
101
102pub mod pack_id {
104 pub const REACTIONS: &str = "adp:reactions/1.0";
105 pub const DELIBERATION: &str = "adp:deliberation/1.0";
106 pub const CURATION: &str = "adp:curation/1.0";
107 pub const MODERATION: &str = "adp:moderation/1.0";
108 pub const REALTIME: &str = "adp:realtime/1.0";
109
110 pub const REGISTERED: [&str; 5] = [REACTIONS, DELIBERATION, CURATION, MODERATION, REALTIME];
111}
112
113#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
114#[serde(rename_all = "snake_case")]
115pub enum RoomState {
116 Scheduled,
117 Active,
118 Ended,
119 Cancelled,
120}
121
122#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
123#[serde(rename_all = "snake_case")]
124pub enum Visibility {
125 Public,
126 Restricted,
127 Private,
128}
129
130#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
131#[serde(rename_all = "snake_case")]
132pub enum Role {
133 Moderator,
134 Speaker,
135 Observer,
136}
137
138#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
140#[serde(rename_all = "snake_case")]
141pub enum TypeKind {
142 Message,
143 Signal,
144 Control,
145}
146
147#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
148#[serde(rename_all = "snake_case")]
149pub enum TypeStatus {
150 Active,
151 Deprecated,
152 Disabled,
153}
154
155#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
157#[serde(rename_all = "snake_case")]
158pub enum BuiltinEventClass {
159 Lifecycle,
160 Signal,
161 Control,
162 Message,
163}
164
165pub fn builtin_event_class(event_type: &str) -> Option<BuiltinEventClass> {
167 match event_type {
168 event_type::ROOM_CREATE
169 | event_type::ROOM_UPDATE
170 | event_type::ROOM_CLOSE
171 | event_type::ROOM_CANCEL => Some(BuiltinEventClass::Lifecycle),
172 event_type::ROOM_JOIN
173 | event_type::ROOM_JOIN_REVIEW
174 | event_type::ROOM_LEAVE
175 | event_type::ROOM_MEMBER_ROLE_UPDATE
176 | event_type::ROOM_MEMBER_REMOVE => Some(BuiltinEventClass::Signal),
177 event_type::TYPE_DEFINE => Some(BuiltinEventClass::Control),
178 event_type::MESSAGE_CREATE => Some(BuiltinEventClass::Message),
179 _ => None,
180 }
181}
182
183pub fn event_advances_room_head(event_type: &str, registry: &TypeRegistry) -> bool {
190 if let Some(class) = builtin_event_class(event_type) {
191 return class != BuiltinEventClass::Signal;
192 }
193 registry
194 .get(event_type)
195 .map(|def| def.kind != TypeKind::Signal)
196 .unwrap_or(true)
197}
198
199#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
200#[serde(rename_all = "snake_case")]
201pub enum JoinRequestStatus {
202 Pending,
203 Approved,
204 Rejected,
205 Expired,
206}
207
208#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
209#[serde(rename_all = "snake_case")]
210pub enum JoinDecision {
211 Approve,
212 Reject,
213}
214
215#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
216pub struct RoomCreatePayload {
217 pub topic: String,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub agenda: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub guidance: Option<String>,
222 pub visibility: Visibility,
223 pub start_time: i64,
224 pub end_time: i64,
225 #[serde(default, skip_serializing_if = "Vec::is_empty")]
226 pub tags: Vec<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub language: Option<String>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub policy: Option<RoomPolicy>,
231 #[serde(default, skip_serializing_if = "Vec::is_empty")]
232 pub types: Vec<TypeDeclaration>,
233 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
234 pub extra: BTreeMap<String, Value>,
235}
236
237impl RoomCreatePayload {
238 pub fn new(
239 topic: impl Into<String>,
240 visibility: Visibility,
241 start_time: i64,
242 end_time: i64,
243 ) -> Self {
244 Self {
245 topic: topic.into(),
246 agenda: None,
247 guidance: None,
248 visibility,
249 start_time,
250 end_time,
251 tags: Vec::new(),
252 language: None,
253 policy: None,
254 types: Vec::new(),
255 extra: BTreeMap::new(),
256 }
257 }
258}
259
260#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
261pub struct RoomPolicy {
262 #[serde(default, skip_serializing_if = "Vec::is_empty")]
263 pub moderator_agent_ids: Vec<AgentId>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub max_speakers: Option<u32>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub observer_allowed: Option<bool>,
268 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
269 pub extra: BTreeMap<String, Value>,
270}
271
272impl RoomPolicy {
273 pub fn new() -> Self {
274 Self::default()
275 }
276}
277
278#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
285#[serde(deny_unknown_fields)]
286pub struct RoomUpdatePayload {
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub topic: Option<String>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub agenda: Option<String>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub guidance: Option<String>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub tags: Option<Vec<String>>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub language: Option<String>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub policy: Option<RoomPolicy>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub start_time: Option<i64>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub end_time: Option<i64>,
303}
304
305impl RoomUpdatePayload {
306 pub fn is_empty(&self) -> bool {
307 self.topic.is_none()
308 && self.agenda.is_none()
309 && self.guidance.is_none()
310 && self.tags.is_none()
311 && self.language.is_none()
312 && self.policy.is_none()
313 && self.start_time.is_none()
314 && self.end_time.is_none()
315 }
316}
317
318#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
320pub struct RoomMemberRemovePayload {
321 pub member: AgentId,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub ban: Option<bool>,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub reason: Option<String>,
327 #[serde(default, skip_serializing_if = "Vec::is_empty")]
328 pub references: Vec<String>,
329 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
330 pub extra: BTreeMap<String, Value>,
331}
332
333impl RoomMemberRemovePayload {
334 pub fn new(member: AgentId) -> Self {
335 Self {
336 member,
337 ban: None,
338 reason: None,
339 references: Vec::new(),
340 extra: BTreeMap::new(),
341 }
342 }
343
344 pub fn banning(&self) -> bool {
345 self.ban.unwrap_or(false)
346 }
347}
348
349#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
351pub struct TypeDef {
352 #[serde(rename = "type")]
353 pub name: String,
354 pub kind: TypeKind,
355 pub title: String,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub description: Option<String>,
358 pub schema: Value,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub roles: Option<Vec<Role>>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub instructions: Option<String>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub version: Option<String>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub status: Option<TypeStatus>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub rate_hint: Option<u32>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub max_payload_hint: Option<u64>,
372 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
373 pub extra: BTreeMap<String, Value>,
374}
375
376impl TypeDef {
377 pub fn status(&self) -> TypeStatus {
378 self.status.unwrap_or(TypeStatus::Active)
379 }
380}
381
382#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
384pub struct TypeOverride {
385 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub roles: Option<Vec<Role>>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub instructions: Option<String>,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub status: Option<TypeStatus>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub rate_hint: Option<u32>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub max_payload_hint: Option<u64>,
395}
396
397#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
399pub struct PackImport {
400 #[serde(rename = "use", default, skip_serializing_if = "Option::is_none")]
401 pub use_pack: Option<String>,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub pack: Option<String>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub digest: Option<String>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub types: Option<Vec<String>>,
408 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
409 pub overrides: BTreeMap<String, TypeOverride>,
410}
411
412#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
414#[serde(untagged)]
415pub enum TypeDeclaration {
416 Def(TypeDef),
417 Import(PackImport),
418}
419
420#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
421pub struct Pack {
422 pub id: String,
423 pub title: String,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub description: Option<String>,
426 pub types: Vec<TypeDef>,
427 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
428 pub extra: BTreeMap<String, Value>,
429}
430
431#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
433pub struct PackDocument {
434 pub protocol: String,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub description: Option<String>,
437 pub packs: Vec<Pack>,
438}
439
440pub fn pack_map(document: &PackDocument) -> BTreeMap<String, Pack> {
442 document
443 .packs
444 .iter()
445 .map(|pack| (pack.id.clone(), pack.clone()))
446 .collect()
447}
448
449#[derive(Clone, Debug, Serialize, Deserialize)]
450pub struct ServerRecord<P = Value> {
451 pub room_id: String,
452 pub seq: u64,
453 #[serde(default)]
454 pub pre_hash: Option<String>,
455 pub hash: String,
456 pub received_at: i64,
457 pub envelope: Envelope<P>,
458}
459
460#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
461pub struct ServerRecordHashPayload {
462 pub room_id: String,
463 pub seq: u64,
464 pub pre_hash: Option<String>,
465 pub envelope_hash: String,
466 pub received_at: i64,
467}
468
469#[derive(Clone, Debug, Serialize, Deserialize)]
470pub struct RoomResponse {
471 pub id: String,
472 pub status: RoomState,
473 pub url: String,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
475 pub creator: Option<AgentId>,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub created_at: Option<i64>,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub topic: Option<String>,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
481 pub agenda: Option<String>,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub guidance: Option<String>,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub visibility: Option<Visibility>,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub start_time: Option<i64>,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub end_time: Option<i64>,
490 #[serde(default, skip_serializing_if = "Vec::is_empty")]
491 pub tags: Vec<String>,
492 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub language: Option<String>,
494 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub policy: Option<RoomPolicy>,
496 #[serde(default, skip_serializing_if = "Vec::is_empty")]
498 pub types: Vec<TypeDef>,
499 pub seq: u64,
500 #[serde(default)]
501 pub pre_hash: Option<String>,
502 pub hash: String,
503 pub received_at: i64,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub head: Option<RoomHead>,
507 #[serde(default, skip_serializing_if = "Option::is_none")]
508 pub envelope: Option<Envelope<RoomCreatePayload>>,
509}
510
511#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
512pub struct RoomHead {
513 pub seq: u64,
514 pub hash: String,
515}
516
517#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
518pub struct RoomJoinPayload {
519 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub request_id: Option<String>,
521 pub role: Role,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub perspective: Option<String>,
524}
525
526#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
527pub struct RoomJoinRequestInput {
528 pub role: Role,
529 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub perspective: Option<String>,
531 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub reason: Option<String>,
533 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
534 pub extra: BTreeMap<String, Value>,
535}
536
537impl RoomJoinRequestInput {
538 pub fn new(role: Role) -> Self {
539 Self {
540 role,
541 perspective: None,
542 reason: None,
543 extra: BTreeMap::new(),
544 }
545 }
546}
547
548#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
549pub struct RoomJoinRequest {
550 pub id: String,
551 pub room_id: String,
552 pub applicant: AgentId,
553 pub role: Role,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub perspective: Option<String>,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub reason: Option<String>,
558 pub created_at: i64,
559 pub expires_at: i64,
560 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
561 pub extra: BTreeMap<String, Value>,
562}
563
564#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
565pub struct RoomJoinRequestStatus {
566 pub request: RoomJoinRequest,
567 pub status: JoinRequestStatus,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub approved_role: Option<Role>,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub review_reason: Option<String>,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
573 pub reviewed_by: Option<AgentId>,
574 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub reviewed_at: Option<i64>,
576}
577
578#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
579pub struct AgentStatusInput {
580 pub state: String,
581 #[serde(default, skip_serializing_if = "Option::is_none")]
582 pub summary: Option<String>,
583 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub seen_seq: Option<u64>,
585 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub seen_hash: Option<String>,
587 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub claim_id: Option<String>,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub activity: Option<String>,
591 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub expires_at: Option<i64>,
594 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
595 pub extra: BTreeMap<String, Value>,
596}
597
598impl AgentStatusInput {
599 pub fn new(state: impl Into<String>) -> Self {
600 Self {
601 state: state.into(),
602 summary: None,
603 seen_seq: None,
604 seen_hash: None,
605 claim_id: None,
606 activity: None,
607 expires_at: None,
608 extra: BTreeMap::new(),
609 }
610 }
611
612 pub fn with_expires_at(mut self, expires_at: i64) -> Self {
613 self.expires_at = Some(expires_at);
614 self
615 }
616}
617
618#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
619pub struct AgentStatus {
620 pub room_id: String,
621 pub agent_id: AgentId,
622 pub state: String,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub summary: Option<String>,
625 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub seen_seq: Option<u64>,
627 #[serde(default, skip_serializing_if = "Option::is_none")]
628 pub seen_hash: Option<String>,
629 #[serde(default, skip_serializing_if = "Option::is_none")]
630 pub claim_id: Option<String>,
631 #[serde(default, skip_serializing_if = "Option::is_none")]
632 pub activity: Option<String>,
633 pub expires_at: i64,
634 pub updated_at: i64,
635 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
636 pub extra: BTreeMap<String, Value>,
637}
638
639#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
640pub struct AgentStatusListResponse {
641 pub statuses: Vec<AgentStatus>,
642}
643
644#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
645pub struct AgentStatusGetResponse {
646 pub status: AgentStatus,
647}
648
649#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
650pub struct RoomJoinReviewPayload {
651 pub request: RoomJoinRequest,
652 pub decision: JoinDecision,
653 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub role: Option<Role>,
655 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub reason: Option<String>,
657 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
658 pub extra: BTreeMap<String, Value>,
659}
660
661#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
662pub struct RoleUpdatePayload {
663 pub member: AgentId,
664 pub role: Role,
665 #[serde(default, skip_serializing_if = "Option::is_none")]
666 pub reason: Option<String>,
667 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
668 pub extra: BTreeMap<String, Value>,
669}
670
671#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
673pub struct ReasonPayload {
674 #[serde(default, skip_serializing_if = "Option::is_none")]
675 pub reason: Option<String>,
676 #[serde(default, skip_serializing_if = "Vec::is_empty")]
677 pub references: Vec<String>,
678 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
679 pub extra: BTreeMap<String, Value>,
680}
681
682pub type RoomLeavePayload = ReasonPayload;
683pub type RoomClosePayload = ReasonPayload;
684pub type RoomCancelPayload = ReasonPayload;
685
686#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
687pub struct MessageCreatePayload {
688 pub content_type: String,
689 pub content: Value,
690 #[serde(default, skip_serializing_if = "Vec::is_empty")]
691 pub references: Vec<String>,
692 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
693 pub extra: BTreeMap<String, Value>,
694}
695
696impl MessageCreatePayload {
697 pub fn new(content_type: impl Into<String>, content: Value) -> Self {
698 Self {
699 content_type: content_type.into(),
700 content,
701 references: Vec::new(),
702 extra: BTreeMap::new(),
703 }
704 }
705
706 pub fn text(text: impl Into<String>) -> Self {
707 Self::new("text/plain", Value::String(text.into()))
708 }
709
710 pub fn markdown(markdown: impl Into<String>) -> Self {
711 Self::new("text/markdown", Value::String(markdown.into()))
712 }
713}
714
715#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
716pub struct ProfileResolverMetadata {
717 pub mode: String,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub service: Option<String>,
720 #[serde(default, skip_serializing_if = "Option::is_none")]
721 pub protocol: Option<String>,
722}
723
724#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
725pub struct DiscourseProtocolDiscovery {
726 pub protocol: String,
727 pub host: String,
728 #[serde(default, skip_serializing_if = "Vec::is_empty")]
729 pub features: Vec<String>,
730 #[serde(default, skip_serializing_if = "Vec::is_empty")]
731 pub registered_packs: Vec<String>,
732 #[serde(default, skip_serializing_if = "Option::is_none")]
733 pub profile: Option<ProfileResolverMetadata>,
734 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
735 pub endpoints: BTreeMap<String, String>,
736}
737
738#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
739pub struct ArchiveManifest {
740 pub protocol: String,
741 #[serde(rename = "type")]
742 pub kind: String,
743 pub host: String,
744 pub room_id: String,
745 pub url: String,
746 pub generated_at: i64,
747 pub event_count: u64,
748 pub first_seq: u64,
749 pub last_seq: u64,
750 pub last_hash: String,
751 pub events_sha3_256: String,
752 pub archive_root: String,
753 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
754 pub formats: BTreeMap<String, String>,
755 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
756 pub extra: BTreeMap<String, Value>,
757}
758
759pub fn room_create_event(
760 actor: AgentId,
761 created_at: i64,
762 nonce: u64,
763 payload: RoomCreatePayload,
764) -> Event<RoomCreatePayload> {
765 Event::new(
766 PROTOCOL,
767 event_type::ROOM_CREATE,
768 actor,
769 created_at,
770 nonce,
771 payload,
772 )
773}
774
775pub fn type_define_event(
776 actor: AgentId,
777 created_at: i64,
778 nonce: u64,
779 room_id: impl Into<String>,
780 base_seq: u64,
781 base_hash: impl Into<String>,
782 declaration: TypeDeclaration,
783) -> Event<TypeDeclaration> {
784 Event::new(
785 PROTOCOL,
786 event_type::TYPE_DEFINE,
787 actor,
788 created_at,
789 nonce,
790 declaration,
791 )
792 .with_room_id(room_id)
793 .with_room_head(base_seq, base_hash)
794}
795
796pub fn discourse_event<P>(
797 kind: impl Into<String>,
798 actor: AgentId,
799 created_at: i64,
800 nonce: u64,
801 room_id: impl Into<String>,
802 base_seq: u64,
803 base_hash: impl Into<String>,
804 payload: P,
805) -> Event<P> {
806 Event::new(PROTOCOL, kind, actor, created_at, nonce, payload)
807 .with_room_id(room_id)
808 .with_room_head(base_seq, base_hash)
809}
810
811pub fn is_builtin_event_type(event_type: &str) -> bool {
812 BUILTIN_EVENT_TYPES.contains(&event_type)
813}
814
815pub fn event_requires_room_id(event_type: &str) -> bool {
816 event_type != event_type::ROOM_CREATE
817}
818
819pub fn validate_discourse_envelope<P>(envelope: &Envelope<P>) -> Result<()>
820where
821 P: Serialize,
822{
823 verify_envelope(envelope)?;
824 let protocol = envelope.event.protocol.as_str();
825 if protocol != PROTOCOL {
826 return Err(SdkError::InvalidEventProtocol {
827 expected: PROTOCOL.to_owned(),
828 actual: envelope.event.protocol.clone(),
829 });
830 }
831 if envelope.event.kind == event_type::ROOM_CREATE {
832 if envelope.event.room_id.is_some() {
833 return Err(SdkError::InvalidPayload(
834 "room.create must not include room_id".to_owned(),
835 ));
836 }
837 if envelope.event.base_seq.is_some()
838 || envelope.event.base_hash.is_some()
839 || !envelope.event.mentions.is_empty()
840 {
841 return Err(SdkError::InvalidPayload(
842 "room.create must not include base_seq, base_hash, or mentions".to_owned(),
843 ));
844 }
845 } else {
846 if envelope.event.room_id.is_none() {
847 return Err(SdkError::MissingRoomId);
848 }
849 validate_room_head_precondition(&envelope.event)?;
850 validate_mentions(&envelope.event.mentions)?;
851 }
852 Ok(())
853}
854
855pub fn validate_room_path<P>(envelope: &Envelope<P>, path_room_id: &str) -> Result<()> {
856 if envelope.event.kind == event_type::ROOM_CREATE {
857 if envelope.event.room_id.is_some() {
858 return Err(SdkError::InvalidPayload(
859 "room.create must not include room_id".to_owned(),
860 ));
861 }
862 if envelope.event.base_seq.is_some()
863 || envelope.event.base_hash.is_some()
864 || !envelope.event.mentions.is_empty()
865 {
866 return Err(SdkError::InvalidPayload(
867 "room.create must not include base_seq, base_hash, or mentions".to_owned(),
868 ));
869 }
870 return Ok(());
871 }
872 validate_room_head_precondition(&envelope.event)?;
873 validate_mentions(&envelope.event.mentions)?;
874 match envelope.event.room_id.as_deref() {
875 Some(actual) if actual == path_room_id => Ok(()),
876 Some(actual) => Err(SdkError::RoomIdMismatch {
877 expected: path_room_id.to_owned(),
878 actual: actual.to_owned(),
879 }),
880 None => Err(SdkError::MissingRoomId),
881 }
882}
883
884fn validate_room_head_precondition<P>(event: &Event<P>) -> Result<()> {
885 match (event.base_seq, event.base_hash.as_deref()) {
886 (Some(seq), Some(hash)) if seq > 0 && seq <= MAX_SAFE_NONCE && !hash.trim().is_empty() => {
887 Ok(())
888 }
889 (Some(0), _) => Err(SdkError::InvalidPayload(
890 "base_seq must be a positive safe JSON integer".to_owned(),
891 )),
892 (Some(_), Some(hash)) if hash.trim().is_empty() => Err(SdkError::InvalidPayload(
893 "base_hash must not be empty".to_owned(),
894 )),
895 (Some(seq), _) if seq > MAX_SAFE_NONCE => Err(SdkError::InvalidPayload(
896 "base_seq must be a safe JSON integer".to_owned(),
897 )),
898 _ => Err(SdkError::InvalidPayload(
899 "room event requires base_seq and base_hash".to_owned(),
900 )),
901 }
902}
903
904fn validate_mentions(mentions: &[AgentId]) -> Result<()> {
905 if mentions.len() > MAX_MENTIONS {
906 return Err(SdkError::InvalidPayload(format!(
907 "mentions must not exceed {MAX_MENTIONS} entries"
908 )));
909 }
910 let unique: BTreeSet<&AgentId> = mentions.iter().collect();
911 if unique.len() != mentions.len() {
912 return Err(SdkError::InvalidPayload(
913 "mentions must be unique".to_owned(),
914 ));
915 }
916 Ok(())
917}
918
919pub fn validate_custom_event_type_name(name: &str) -> Result<()> {
922 let segments: Vec<&str> = name.split('.').collect();
923 let valid_shape = segments.len() >= 2 && segments.iter().all(|segment| {
924 let mut chars = segment.chars();
925 matches!(chars.next(), Some(first) if first.is_ascii_lowercase() || first.is_ascii_digit())
926 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
927 });
928 if !valid_shape {
929 return Err(SdkError::InvalidPayload(format!(
930 "invalid event type name: {name}"
931 )));
932 }
933 if is_builtin_event_type(name) {
934 return Err(SdkError::InvalidPayload(format!(
935 "{name} is a built-in event type"
936 )));
937 }
938 if RESERVED_TYPE_PREFIXES
939 .iter()
940 .any(|prefix| name.starts_with(prefix))
941 {
942 return Err(SdkError::InvalidPayload(format!(
943 "{name} uses a reserved type prefix"
944 )));
945 }
946 Ok(())
947}
948
949pub fn validate_type_def(def: &TypeDef) -> Result<()> {
950 validate_custom_event_type_name(&def.name)?;
951 if def.title.trim().is_empty() {
952 return Err(SdkError::InvalidPayload(
953 "type definition title must not be empty".to_owned(),
954 ));
955 }
956 if !def.schema.is_object() {
957 return Err(SdkError::InvalidPayload(
958 "type definition schema must be a JSON Schema object".to_owned(),
959 ));
960 }
961 compile_schema(&def.schema)?;
962 if matches!(&def.roles, Some(roles) if roles.is_empty()) {
963 return Err(SdkError::InvalidPayload(
964 "type definition roles must not be empty".to_owned(),
965 ));
966 }
967 if matches!(def.rate_hint, Some(0)) || matches!(def.max_payload_hint, Some(0)) {
968 return Err(SdkError::InvalidPayload(
969 "type definition hints must be positive".to_owned(),
970 ));
971 }
972 Ok(())
973}
974
975pub fn validate_pack_import(import: &PackImport) -> Result<()> {
976 match (&import.use_pack, &import.pack, &import.digest) {
977 (Some(id), None, None) => {
978 if !is_registered_pack_id(id) {
979 return Err(SdkError::InvalidPayload(format!(
980 "invalid registered pack id: {id}"
981 )));
982 }
983 }
984 (None, Some(_), Some(digest)) => {
985 if digest.trim().is_empty() {
986 return Err(SdkError::InvalidPayload(
987 "external pack digest must not be empty".to_owned(),
988 ));
989 }
990 }
991 _ => {
992 return Err(SdkError::InvalidPayload(
993 "pack import requires either use, or pack with digest".to_owned(),
994 ));
995 }
996 }
997 if matches!(&import.types, Some(types) if types.is_empty()) {
998 return Err(SdkError::InvalidPayload(
999 "pack import types subset must not be empty".to_owned(),
1000 ));
1001 }
1002 Ok(())
1003}
1004
1005pub fn validate_type_declaration(declaration: &TypeDeclaration) -> Result<()> {
1006 match declaration {
1007 TypeDeclaration::Def(def) => validate_type_def(def),
1008 TypeDeclaration::Import(import) => validate_pack_import(import),
1009 }
1010}
1011
1012fn is_registered_pack_id(id: &str) -> bool {
1013 let Some(rest) = id.strip_prefix("adp:") else {
1014 return false;
1015 };
1016 let Some((name, version)) = rest.split_once('/') else {
1017 return false;
1018 };
1019 !name.is_empty()
1020 && name
1021 .chars()
1022 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1023 && version.split('.').count() == 2
1024 && version
1025 .split('.')
1026 .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
1027}
1028
1029pub fn validate_room_create_payload(payload: &RoomCreatePayload) -> Result<()> {
1030 if payload.topic.trim().is_empty() {
1031 return Err(SdkError::InvalidPayload(
1032 "room topic must not be empty".to_owned(),
1033 ));
1034 }
1035 if payload.start_time >= payload.end_time {
1036 return Err(SdkError::InvalidPayload(
1037 "start_time must be before end_time".to_owned(),
1038 ));
1039 }
1040 if let Some(policy) = &payload.policy {
1041 if matches!(policy.max_speakers, Some(0)) {
1042 return Err(SdkError::InvalidPayload(
1043 "max_speakers must be a positive integer".to_owned(),
1044 ));
1045 }
1046 }
1047 for declaration in &payload.types {
1048 validate_type_declaration(declaration)?;
1049 }
1050 Ok(())
1051}
1052
1053pub fn validate_message_create_payload(payload: &MessageCreatePayload) -> Result<()> {
1054 if payload.content_type.trim().is_empty() {
1055 return Err(SdkError::InvalidPayload(
1056 "content_type must not be empty".to_owned(),
1057 ));
1058 }
1059 Ok(())
1060}
1061
1062pub fn validate_room_join_payload(payload: &RoomJoinPayload) -> Result<()> {
1063 if matches!(&payload.request_id, Some(request_id) if request_id.trim().is_empty()) {
1064 return Err(SdkError::InvalidPayload(
1065 "request_id must not be empty".to_owned(),
1066 ));
1067 }
1068 if payload.request_id.is_some() && payload.perspective.is_some() {
1069 return Err(SdkError::InvalidPayload(
1070 "room.join payload cannot include both request_id and perspective".to_owned(),
1071 ));
1072 }
1073 Ok(())
1074}
1075
1076pub fn validate_room_update_payload(payload: &RoomUpdatePayload) -> Result<()> {
1080 if payload.is_empty() {
1081 return Err(SdkError::InvalidPayload(
1082 "room.update payload must not be empty".to_owned(),
1083 ));
1084 }
1085 if matches!(&payload.topic, Some(topic) if topic.trim().is_empty()) {
1086 return Err(SdkError::InvalidPayload(
1087 "room topic must not be empty".to_owned(),
1088 ));
1089 }
1090 if let (Some(start_time), Some(end_time)) = (payload.start_time, payload.end_time) {
1091 if start_time >= end_time {
1092 return Err(SdkError::InvalidPayload(
1093 "start_time must be before end_time".to_owned(),
1094 ));
1095 }
1096 }
1097 if let Some(policy) = &payload.policy {
1098 if matches!(policy.max_speakers, Some(0)) {
1099 return Err(SdkError::InvalidPayload(
1100 "max_speakers must be a positive integer".to_owned(),
1101 ));
1102 }
1103 }
1104 Ok(())
1105}
1106
1107pub fn validate_room_member_remove_payload(payload: &RoomMemberRemovePayload) -> Result<()> {
1110 payload.member.public_key_bytes()?;
1111 Ok(())
1112}
1113
1114#[derive(Clone, Debug, Default)]
1116pub struct TypeRegistry {
1117 types: BTreeMap<String, TypeDef>,
1118}
1119
1120impl TypeRegistry {
1121 pub fn new() -> Self {
1122 Self::default()
1123 }
1124
1125 pub fn from_declarations(
1128 declarations: &[TypeDeclaration],
1129 packs: &BTreeMap<String, Pack>,
1130 ) -> Result<Self> {
1131 let mut registry = Self::new();
1132 for declaration in declarations {
1133 registry.apply(declaration, packs)?;
1134 }
1135 Ok(registry)
1136 }
1137
1138 pub fn apply(
1141 &mut self,
1142 declaration: &TypeDeclaration,
1143 packs: &BTreeMap<String, Pack>,
1144 ) -> Result<()> {
1145 match declaration {
1146 TypeDeclaration::Def(def) => self.define(def.clone()),
1147 TypeDeclaration::Import(import) => self.import(import, packs),
1148 }
1149 }
1150
1151 pub fn define(&mut self, def: TypeDef) -> Result<()> {
1152 validate_type_def(&def)?;
1153 if let Some(existing) = self.types.get(&def.name) {
1154 if existing.kind != def.kind {
1155 return Err(SdkError::InvalidPayload(format!(
1156 "type {} cannot change kind on redefinition",
1157 def.name
1158 )));
1159 }
1160 }
1161 self.types.insert(def.name.clone(), def);
1162 Ok(())
1163 }
1164
1165 fn import(&mut self, import: &PackImport, packs: &BTreeMap<String, Pack>) -> Result<()> {
1166 validate_pack_import(import)?;
1167 let reference = import
1168 .use_pack
1169 .as_deref()
1170 .or(import.pack.as_deref())
1171 .expect("validated pack import has a reference");
1172 let pack = packs
1173 .get(reference)
1174 .ok_or_else(|| SdkError::PackUnavailable(reference.to_owned()))?;
1175 let available: BTreeSet<&str> = pack.types.iter().map(|def| def.name.as_str()).collect();
1176 if let Some(subset) = &import.types {
1177 for name in subset {
1178 if !available.contains(name.as_str()) {
1179 return Err(SdkError::PackUnavailable(format!(
1180 "type {name} is not in pack {reference}"
1181 )));
1182 }
1183 }
1184 }
1185 for name in import.overrides.keys() {
1186 let imported = import
1187 .types
1188 .as_ref()
1189 .map(|subset| subset.contains(name))
1190 .unwrap_or_else(|| available.contains(name.as_str()));
1191 if !imported {
1192 return Err(SdkError::InvalidPayload(format!(
1193 "override target {name} is not imported from pack {reference}"
1194 )));
1195 }
1196 }
1197 for def in &pack.types {
1198 if let Some(subset) = &import.types {
1199 if !subset.contains(&def.name) {
1200 continue;
1201 }
1202 }
1203 let mut def = def.clone();
1204 if let Some(over) = import.overrides.get(&def.name) {
1205 if let Some(roles) = &over.roles {
1206 def.roles = Some(roles.clone());
1207 }
1208 if let Some(instructions) = &over.instructions {
1209 def.instructions = Some(instructions.clone());
1210 }
1211 if let Some(status) = over.status {
1212 def.status = Some(status);
1213 }
1214 if let Some(rate_hint) = over.rate_hint {
1215 def.rate_hint = Some(rate_hint);
1216 }
1217 if let Some(max_payload_hint) = over.max_payload_hint {
1218 def.max_payload_hint = Some(max_payload_hint);
1219 }
1220 }
1221 self.define(def)?;
1222 }
1223 Ok(())
1224 }
1225
1226 pub fn get(&self, event_type: &str) -> Option<&TypeDef> {
1227 self.types.get(event_type)
1228 }
1229
1230 pub fn contains(&self, event_type: &str) -> bool {
1231 self.types.contains_key(event_type)
1232 }
1233
1234 pub fn len(&self) -> usize {
1235 self.types.len()
1236 }
1237
1238 pub fn is_empty(&self) -> bool {
1239 self.types.is_empty()
1240 }
1241
1242 pub fn definitions(&self) -> impl Iterator<Item = &TypeDef> {
1243 self.types.values()
1244 }
1245
1246 pub fn validate_payload(&self, event_type: &str, payload: &Value) -> Result<()> {
1248 let def = self
1249 .get(event_type)
1250 .ok_or_else(|| SdkError::TypeNotDefined(event_type.to_owned()))?;
1251 if def.status() == TypeStatus::Disabled {
1252 return Err(SdkError::TypeDisabled(event_type.to_owned()));
1253 }
1254 let validator = compile_schema(&def.schema)?;
1255 if let Err(error) = validator.validate(payload) {
1256 return Err(SdkError::PayloadSchemaViolation(format!(
1257 "{event_type}: {error}"
1258 )));
1259 }
1260 Ok(())
1261 }
1262}
1263
1264pub fn validate_event_against_registry(
1267 event_type: &str,
1268 payload: &Value,
1269 registry: &TypeRegistry,
1270) -> Result<()> {
1271 if is_builtin_event_type(event_type) {
1272 return Ok(());
1273 }
1274 registry.validate_payload(event_type, payload)
1275}
1276
1277fn compile_schema(schema: &Value) -> Result<jsonschema::Validator> {
1278 jsonschema::options()
1279 .with_draft(jsonschema::Draft::Draft202012)
1280 .build(schema)
1281 .map_err(|err| SdkError::InvalidPayload(format!("invalid type schema: {err}")))
1282}
1283
1284pub fn verify_pack_digest(bytes: &[u8], digest: &str) -> Result<()> {
1287 let (algorithm, expected) = digest
1288 .split_once(':')
1289 .ok_or_else(|| SdkError::PackUnavailable(format!("invalid digest format: {digest}")))?;
1290 let actual = match algorithm {
1291 "sha256" => URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)),
1292 "sha3-256" => URL_SAFE_NO_PAD.encode(Sha3_256::digest(bytes)),
1293 _ => {
1294 return Err(SdkError::PackUnavailable(format!(
1295 "unsupported digest algorithm: {algorithm}"
1296 )));
1297 }
1298 };
1299 if actual == expected {
1300 Ok(())
1301 } else {
1302 Err(SdkError::PackUnavailable("pack digest mismatch".to_owned()))
1303 }
1304}
1305
1306pub fn server_record_hash_payload(
1307 room_id: &str,
1308 seq: u64,
1309 pre_hash: Option<&str>,
1310 envelope_hash: &str,
1311 received_at: i64,
1312) -> ServerRecordHashPayload {
1313 ServerRecordHashPayload {
1314 room_id: room_id.to_owned(),
1315 seq,
1316 pre_hash: pre_hash.map(str::to_owned),
1317 envelope_hash: envelope_hash.to_owned(),
1318 received_at,
1319 }
1320}
1321
1322pub fn server_record_hash(
1323 room_id: &str,
1324 seq: u64,
1325 pre_hash: Option<&str>,
1326 envelope_hash: &str,
1327 received_at: i64,
1328) -> Result<String> {
1329 hash_canonical_json(&server_record_hash_payload(
1330 room_id,
1331 seq,
1332 pre_hash,
1333 envelope_hash,
1334 received_at,
1335 ))
1336}
1337
1338pub fn build_server_record<P>(
1339 room_id: impl Into<String>,
1340 seq: u64,
1341 pre_hash: Option<String>,
1342 received_at: i64,
1343 envelope: Envelope<P>,
1344) -> Result<ServerRecord<P>> {
1345 let room_id = room_id.into();
1346 let hash = server_record_hash(
1347 &room_id,
1348 seq,
1349 pre_hash.as_deref(),
1350 &envelope.hash,
1351 received_at,
1352 )
1353 .expect("server record hash payload is always serializable");
1354 Ok(ServerRecord {
1355 room_id,
1356 seq,
1357 pre_hash,
1358 hash,
1359 received_at,
1360 envelope,
1361 })
1362}
1363
1364pub fn verify_server_record<P>(record: &ServerRecord<P>) -> Result<()>
1365where
1366 P: Serialize,
1367{
1368 let expected = server_record_hash(
1369 &record.room_id,
1370 record.seq,
1371 record.pre_hash.as_deref(),
1372 &record.envelope.hash,
1373 record.received_at,
1374 )
1375 .expect("server record hash payload is always serializable");
1376 if record.hash == expected {
1377 Ok(())
1378 } else {
1379 Err(SdkError::InvalidEventHash {
1380 expected,
1381 actual: record.hash.clone(),
1382 })
1383 }
1384}
1385
1386pub fn verify_server_record_chain<P>(records: &[ServerRecord<P>]) -> Result<()>
1387where
1388 P: Serialize,
1389{
1390 let mut previous: Option<&ServerRecord<P>> = None;
1391 for record in records {
1392 verify_server_record(record)?;
1393 if let Some(previous) = previous {
1394 if record.seq != previous.seq + 1 {
1395 return Err(SdkError::InvalidPayload(
1396 "seq must increase by 1".to_owned(),
1397 ));
1398 }
1399 if record.pre_hash.as_deref() != Some(previous.hash.as_str()) {
1400 return Err(SdkError::InvalidPayload("pre_hash mismatch".to_owned()));
1401 }
1402 } else if record.seq != 1 {
1403 return Err(SdkError::InvalidPayload("first seq must be 1".to_owned()));
1404 } else if record.pre_hash.is_some() {
1405 return Err(SdkError::InvalidPayload(
1406 "first pre_hash must be null".to_owned(),
1407 ));
1408 }
1409 previous = Some(record);
1410 }
1411 Ok(())
1412}
1413
1414pub fn archive_events_digest<P>(records: &[ServerRecord<P>]) -> Result<String>
1415where
1416 P: Serialize,
1417{
1418 hash_canonical_json(records)
1419}
1420
1421#[derive(Clone, Debug, Default, PartialEq, Eq)]
1423pub struct PermissionContext {
1424 pub role: Option<Role>,
1425 pub is_creator: bool,
1426 pub public_join_allowed: bool,
1427 pub join_request_approved: bool,
1428}
1429
1430impl PermissionContext {
1431 pub fn for_role(role: Role) -> Self {
1432 Self {
1433 role: Some(role),
1434 ..Self::default()
1435 }
1436 }
1437
1438 pub fn creator(role: Option<Role>) -> Self {
1439 Self {
1440 role,
1441 is_creator: true,
1442 ..Self::default()
1443 }
1444 }
1445}
1446
1447pub fn default_kind_roles(kind: TypeKind) -> &'static [Role] {
1449 match kind {
1450 TypeKind::Message => &[Role::Moderator, Role::Speaker],
1451 TypeKind::Signal => &[Role::Moderator, Role::Speaker, Role::Observer],
1452 TypeKind::Control => &[Role::Moderator],
1453 }
1454}
1455
1456pub fn can_submit_event(
1459 event_type: &str,
1460 context: &PermissionContext,
1461 registry: &TypeRegistry,
1462) -> bool {
1463 match event_type {
1464 event_type::ROOM_CREATE => true,
1465 event_type::ROOM_JOIN => context.public_join_allowed || context.join_request_approved,
1466 event_type::ROOM_LEAVE => context.is_creator || context.role.is_some(),
1467 event_type::ROOM_UPDATE
1468 | event_type::ROOM_JOIN_REVIEW
1469 | event_type::ROOM_MEMBER_ROLE_UPDATE
1470 | event_type::ROOM_MEMBER_REMOVE
1471 | event_type::ROOM_CLOSE
1472 | event_type::ROOM_CANCEL
1473 | event_type::TYPE_DEFINE => context.is_creator || context.role == Some(Role::Moderator),
1474 event_type::MESSAGE_CREATE => {
1475 context.is_creator
1476 || matches!(context.role, Some(Role::Moderator) | Some(Role::Speaker))
1477 }
1478 custom => {
1479 let Some(def) = registry.get(custom) else {
1480 return false;
1481 };
1482 if def.status() == TypeStatus::Disabled {
1483 return false;
1484 }
1485 if context.is_creator {
1486 return true;
1487 }
1488 let Some(role) = context.role else {
1489 return false;
1490 };
1491 match &def.roles {
1492 Some(roles) => roles.contains(&role),
1493 None => default_kind_roles(def.kind).contains(&role),
1494 }
1495 }
1496 }
1497}
1498
1499pub fn can_write_in_state(event_type: &str, state: RoomState) -> bool {
1500 match state {
1501 RoomState::Scheduled => matches!(
1502 event_type,
1503 event_type::ROOM_JOIN
1504 | event_type::ROOM_JOIN_REVIEW
1505 | event_type::ROOM_MEMBER_ROLE_UPDATE
1506 | event_type::ROOM_MEMBER_REMOVE
1507 | event_type::ROOM_LEAVE
1508 | event_type::ROOM_UPDATE
1509 | event_type::TYPE_DEFINE
1510 | event_type::ROOM_CANCEL
1511 ),
1512 RoomState::Active => {
1513 event_type != event_type::ROOM_CREATE && event_type != event_type::ROOM_CANCEL
1514 }
1515 RoomState::Ended | RoomState::Cancelled => false,
1516 }
1517}
1518
1519pub fn can_accept_room_write(
1520 event_type: &str,
1521 state: RoomState,
1522 permission: &PermissionContext,
1523 registry: &TypeRegistry,
1524) -> bool {
1525 can_submit_event(event_type, permission, registry) && can_write_in_state(event_type, state)
1526}
1527
1528pub fn validate_room_write(
1529 event_type: &str,
1530 state: RoomState,
1531 permission: &PermissionContext,
1532 registry: &TypeRegistry,
1533) -> Result<()> {
1534 if can_accept_room_write(event_type, state, permission, registry) {
1535 Ok(())
1536 } else {
1537 Err(SdkError::PermissionDenied)
1538 }
1539}
1540
1541fn hash_canonical_json<T>(value: &T) -> Result<String>
1542where
1543 T: Serialize + ?Sized,
1544{
1545 let bytes = serde_jcs::to_vec(value).map_err(|err| SdkError::CanonicalJson(err.to_string()))?;
1546 let digest = Sha3_256::digest(bytes);
1547 Ok(URL_SAFE_NO_PAD.encode(digest))
1548}
1549
1550#[cfg(test)]
1551mod tests {
1552 use super::*;
1553 use crate::identity::AgentSigner;
1554 use serde::ser;
1555 use serde_json::json;
1556
1557 struct FailingPayload;
1558
1559 impl Serialize for FailingPayload {
1560 fn serialize<S>(&self, _serializer: S) -> std::result::Result<S::Ok, S::Error>
1561 where
1562 S: serde::Serializer,
1563 {
1564 Err(ser::Error::custom("payload cannot be serialized"))
1565 }
1566 }
1567
1568 fn registered_packs() -> BTreeMap<String, Pack> {
1569 let raw = std::fs::read_to_string(concat!(
1570 env!("CARGO_MANIFEST_DIR"),
1571 "/../../docs/protocols/agent-discourse/1.0.packs.json"
1572 ))
1573 .expect("read registered packs");
1574 let document: PackDocument = serde_json::from_str(&raw).expect("parse registered packs");
1575 assert_eq!(document.protocol, PROTOCOL);
1576 pack_map(&document)
1577 }
1578
1579 fn finding_def() -> TypeDef {
1580 TypeDef {
1581 name: "review.finding".to_owned(),
1582 kind: TypeKind::Message,
1583 title: "Review finding".to_owned(),
1584 description: None,
1585 schema: json!({
1586 "type": "object",
1587 "required": ["severity", "summary"],
1588 "properties": {
1589 "severity": {"type": "string", "enum": ["low", "medium", "high"]},
1590 "summary": {"type": "string", "minLength": 1}
1591 },
1592 "additionalProperties": false
1593 }),
1594 roles: None,
1595 instructions: None,
1596 version: None,
1597 status: None,
1598 rate_hint: None,
1599 max_payload_hint: None,
1600 extra: BTreeMap::new(),
1601 }
1602 }
1603
1604 fn join_review_payload(applicant: AgentId) -> RoomJoinReviewPayload {
1605 RoomJoinReviewPayload {
1606 request: RoomJoinRequest {
1607 id: "jr_01J8ZM7A3G2T9B4Q6X8R0N1P2Q".to_owned(),
1608 room_id: "room1".to_owned(),
1609 applicant,
1610 role: Role::Speaker,
1611 perspective: None,
1612 reason: None,
1613 created_at: 100,
1614 expires_at: 200,
1615 extra: BTreeMap::new(),
1616 },
1617 decision: JoinDecision::Approve,
1618 role: Some(Role::Speaker),
1619 reason: None,
1620 extra: BTreeMap::new(),
1621 }
1622 }
1623
1624 #[test]
1625 fn kernel_defines_eleven_builtins_with_membership_signals() {
1626 assert_eq!(BUILTIN_EVENT_TYPES.len(), 11);
1627 assert!(is_builtin_event_type(event_type::ROOM_UPDATE));
1628 assert!(is_builtin_event_type(event_type::ROOM_MEMBER_REMOVE));
1629
1630 let registry = TypeRegistry::new();
1631 for membership in MEMBERSHIP_EVENT_TYPES {
1632 assert_eq!(
1633 builtin_event_class(membership),
1634 Some(BuiltinEventClass::Signal)
1635 );
1636 assert!(!event_advances_room_head(membership, ®istry));
1637 }
1638 for advancing in [
1639 event_type::ROOM_CREATE,
1640 event_type::ROOM_UPDATE,
1641 event_type::ROOM_CLOSE,
1642 event_type::ROOM_CANCEL,
1643 event_type::TYPE_DEFINE,
1644 event_type::MESSAGE_CREATE,
1645 ] {
1646 assert!(event_advances_room_head(advancing, ®istry));
1647 }
1648 assert_eq!(builtin_event_class("review.finding"), None);
1649 assert!(event_advances_room_head("unknown.type", ®istry));
1651
1652 let mut registry = TypeRegistry::new();
1653 let mut signal_def = finding_def();
1654 signal_def.name = "reaction.create".to_owned();
1655 signal_def.kind = TypeKind::Signal;
1656 registry.define(signal_def).unwrap();
1657 assert!(!event_advances_room_head("reaction.create", ®istry));
1658
1659 assert!(DISCOURSE_ERROR_CODES.contains(&"member_banned"));
1660 assert!(DISCOURSE_ERROR_CODES.contains(&"role_not_allowed"));
1661 assert!(DISCOURSE_ERROR_CODES.contains(&"max_speakers_exceeded"));
1662 }
1663
1664 #[test]
1665 fn room_update_and_member_remove_follow_moderator_rules() {
1666 let registry = TypeRegistry::new();
1667 let moderator = PermissionContext::for_role(Role::Moderator);
1668 let speaker = PermissionContext::for_role(Role::Speaker);
1669 let creator = PermissionContext::creator(None);
1670 for builtin in [event_type::ROOM_UPDATE, event_type::ROOM_MEMBER_REMOVE] {
1671 assert!(can_submit_event(builtin, &moderator, ®istry));
1672 assert!(can_submit_event(builtin, &creator, ®istry));
1673 assert!(!can_submit_event(builtin, &speaker, ®istry));
1674 assert!(can_write_in_state(builtin, RoomState::Scheduled));
1675 assert!(can_write_in_state(builtin, RoomState::Active));
1676 assert!(!can_write_in_state(builtin, RoomState::Ended));
1677 assert!(!can_write_in_state(builtin, RoomState::Cancelled));
1678 }
1679 }
1680
1681 #[test]
1682 fn validates_room_update_payloads() {
1683 let valid = RoomUpdatePayload {
1684 topic: Some("New topic".to_owned()),
1685 guidance: Some(String::new()),
1686 end_time: Some(2000),
1687 ..RoomUpdatePayload::default()
1688 };
1689 validate_room_update_payload(&valid).unwrap();
1690
1691 assert!(validate_room_update_payload(&RoomUpdatePayload::default()).is_err());
1692 assert!(validate_room_update_payload(&RoomUpdatePayload {
1693 topic: Some(" ".to_owned()),
1694 ..RoomUpdatePayload::default()
1695 })
1696 .is_err());
1697 assert!(validate_room_update_payload(&RoomUpdatePayload {
1698 start_time: Some(5),
1699 end_time: Some(5),
1700 ..RoomUpdatePayload::default()
1701 })
1702 .is_err());
1703 assert!(validate_room_update_payload(&RoomUpdatePayload {
1704 policy: Some(RoomPolicy {
1705 max_speakers: Some(0),
1706 ..RoomPolicy::default()
1707 }),
1708 ..RoomUpdatePayload::default()
1709 })
1710 .is_err());
1711
1712 serde_json::from_value::<RoomUpdatePayload>(json!({"topic": "New topic"})).unwrap();
1715 assert!(serde_json::from_value::<RoomUpdatePayload>(
1716 json!({"topic": "New topic", "visibility": "private"})
1717 )
1718 .is_err());
1719 }
1720
1721 #[test]
1722 fn validates_room_member_remove_payloads() {
1723 let member = AgentSigner::from_seed([41; 32]).agent_id();
1724 let mut payload = RoomMemberRemovePayload::new(member);
1725 validate_room_member_remove_payload(&payload).unwrap();
1726 assert!(!payload.banning());
1727 payload.ban = Some(true);
1728 assert!(payload.banning());
1729 validate_room_member_remove_payload(&payload).unwrap();
1730 }
1731
1732 #[test]
1733 fn mentions_are_capped_at_32_unique_agent_ids() {
1734 let signer = AgentSigner::from_seed([42; 32]);
1735 let mentions: Vec<AgentId> = (0..33)
1736 .map(|index| AgentSigner::from_seed([100 + index as u8; 32]).agent_id())
1737 .collect();
1738 let event = |mentions: Vec<AgentId>| {
1739 discourse_event(
1740 event_type::MESSAGE_CREATE,
1741 signer.agent_id(),
1742 100,
1743 1,
1744 "room1",
1745 1,
1746 "room-create-head",
1747 MessageCreatePayload::text("hi"),
1748 )
1749 .with_mentions(mentions)
1750 };
1751
1752 let ok = signer.sign_event(event(mentions[..32].to_vec())).unwrap();
1753 validate_discourse_envelope(&ok).unwrap();
1754
1755 let too_many = signer.sign_event(event(mentions.clone())).unwrap();
1756 assert!(validate_discourse_envelope(&too_many).is_err());
1757
1758 let duplicated = signer
1759 .sign_event(event(vec![mentions[0].clone(), mentions[0].clone()]))
1760 .unwrap();
1761 assert!(validate_discourse_envelope(&duplicated).is_err());
1762 assert!(validate_room_path(&duplicated, "room1").is_err());
1763 }
1764
1765 #[test]
1766 fn type_redefinition_cannot_change_kind() {
1767 let mut registry = TypeRegistry::new();
1768 registry.define(finding_def()).unwrap();
1769 let mut retitled = finding_def();
1770 retitled.title = "Finding v2".to_owned();
1771 registry.define(retitled).unwrap();
1772 assert_eq!(registry.get("review.finding").unwrap().title, "Finding v2");
1773
1774 let mut flipped = finding_def();
1775 flipped.kind = TypeKind::Signal;
1776 assert!(registry.define(flipped).is_err());
1777 }
1778
1779 #[test]
1780 fn validates_room_create_without_room_id() {
1781 let signer = AgentSigner::from_seed([14; 32]);
1782 let payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1783 let envelope = signer
1784 .sign_event(room_create_event(signer.agent_id(), 100, 1, payload))
1785 .unwrap();
1786
1787 validate_discourse_envelope(&envelope).unwrap();
1788 validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").unwrap();
1789 }
1790
1791 #[test]
1792 fn rejects_room_create_with_room_id() {
1793 let signer = AgentSigner::from_seed([14; 32]);
1794 let payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1795 let event = room_create_event(signer.agent_id(), 100, 1, payload)
1796 .with_room_id("d8ftedhpqhsusbg001tg");
1797 let envelope = signer.sign_event(event).unwrap();
1798
1799 assert!(validate_discourse_envelope(&envelope).is_err());
1800 assert!(validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").is_err());
1801 }
1802
1803 #[test]
1804 fn rejects_room_event_without_room_id() {
1805 let signer = AgentSigner::from_seed([15; 32]);
1806 let event = Event::new(
1807 PROTOCOL,
1808 event_type::MESSAGE_CREATE,
1809 signer.agent_id(),
1810 100,
1811 1,
1812 MessageCreatePayload::text("hello"),
1813 );
1814 let envelope = signer.sign_event(event).unwrap();
1815
1816 assert!(validate_discourse_envelope(&envelope).is_err());
1817 }
1818
1819 #[test]
1820 fn rejects_invalid_discourse_envelope_signature() {
1821 let signer = AgentSigner::from_seed([16; 32]);
1822 let event = discourse_event(
1823 event_type::MESSAGE_CREATE,
1824 signer.agent_id(),
1825 100,
1826 1,
1827 "room1",
1828 1,
1829 "room-head-hash",
1830 MessageCreatePayload::text("hello"),
1831 );
1832 let mut envelope = signer.sign_event(event).unwrap();
1833 envelope.signature = "invalid".to_owned();
1834
1835 assert!(validate_discourse_envelope(&envelope).is_err());
1836 }
1837
1838 #[test]
1839 fn validates_join_review_canonical_request() {
1840 let moderator = AgentSigner::from_seed([21; 32]);
1841 let applicant = AgentSigner::from_seed([22; 32]);
1842 let payload = RoomJoinReviewPayload {
1843 request: RoomJoinRequest {
1844 id: "jr_01J8ZM7A3G2T9B4Q6X8R0N1P2Q".to_owned(),
1845 room_id: "d8ftedhpqhsusbg001tg".to_owned(),
1846 applicant: applicant.agent_id(),
1847 role: Role::Speaker,
1848 perspective: Some("distributed-systems reviewer".to_owned()),
1849 reason: Some("I can cover replication and failure-mode tradeoffs.".to_owned()),
1850 created_at: 1_779_757_210_000,
1851 expires_at: 1_779_760_810_000,
1852 extra: BTreeMap::new(),
1853 },
1854 decision: JoinDecision::Approve,
1855 role: Some(Role::Speaker),
1856 reason: Some("relevant expertise".to_owned()),
1857 extra: BTreeMap::new(),
1858 };
1859 let envelope = moderator
1860 .sign_event(discourse_event(
1861 event_type::ROOM_JOIN_REVIEW,
1862 moderator.agent_id(),
1863 1_779_757_250_000,
1864 1,
1865 "d8ftedhpqhsusbg001tg",
1866 1,
1867 "room-head-hash",
1868 payload.clone(),
1869 ))
1870 .unwrap();
1871
1872 validate_discourse_envelope(&envelope).unwrap();
1873 let value = serde_json::to_value(payload).unwrap();
1874 assert!(value.get("member").is_none());
1875 assert_eq!(
1876 value["request"]["applicant"],
1877 json!(applicant.agent_id().to_string())
1878 );
1879 assert_eq!(value["request"]["role"], json!("speaker"));
1880 }
1881
1882 #[test]
1883 fn role_serde_matches_spec() {
1884 assert_eq!(
1885 serde_json::to_string(&Role::Speaker).unwrap(),
1886 "\"speaker\""
1887 );
1888 assert!(serde_json::from_str::<Role>("\"expert\"").is_err());
1889 assert!(serde_json::from_str::<Role>("\"participant\"").is_err());
1890 }
1891
1892 #[test]
1893 fn room_response_retains_room_metadata_fields() {
1894 let room: RoomResponse = serde_json::from_value(json!({
1895 "id": "room1",
1896 "status": "active",
1897 "url": "https://api.example.test/v1/rooms/room1",
1898 "topic": "Room",
1899 "agenda": "Review the proposal",
1900 "guidance": "Stay concise",
1901 "visibility": "public",
1902 "start_time": 1,
1903 "end_time": 2,
1904 "tags": ["review"],
1905 "language": "en",
1906 "seq": 7,
1907 "pre_hash": "previous",
1908 "hash": "current",
1909 "received_at": 3
1910 }))
1911 .unwrap();
1912
1913 assert_eq!(room.agenda.as_deref(), Some("Review the proposal"));
1914 assert_eq!(room.tags, vec!["review"]);
1915 assert_eq!(room.language.as_deref(), Some("en"));
1916 assert_eq!(
1917 serde_json::to_value(&room).unwrap()["agenda"],
1918 json!("Review the proposal")
1919 );
1920 }
1921
1922 #[test]
1923 fn validates_custom_event_type_names() {
1924 validate_custom_event_type_name("review.finding").unwrap();
1925 validate_custom_event_type_name("poll.vote").unwrap();
1926 assert!(validate_custom_event_type_name("freeform").is_err());
1927 assert!(validate_custom_event_type_name("room.custom").is_err());
1928 assert!(validate_custom_event_type_name("type.new").is_err());
1929 assert!(validate_custom_event_type_name("message.create").is_err());
1930 assert!(validate_custom_event_type_name("Bad.Name").is_err());
1931 assert!(validate_custom_event_type_name(".finding").is_err());
1932 assert!(validate_custom_event_type_name("review.").is_err());
1933 assert!(validate_custom_event_type_name("review.finding!").is_err());
1934 }
1935
1936 #[test]
1937 fn materializes_registry_from_packs_and_inline_defs() {
1938 let packs = registered_packs();
1939 let declarations = vec![
1940 TypeDeclaration::Import(PackImport {
1941 use_pack: Some(pack_id::REACTIONS.to_owned()),
1942 ..PackImport::default()
1943 }),
1944 TypeDeclaration::Import(PackImport {
1945 use_pack: Some(pack_id::DELIBERATION.to_owned()),
1946 overrides: BTreeMap::from([(
1947 "poll.vote".to_owned(),
1948 TypeOverride {
1949 roles: Some(vec![Role::Moderator, Role::Speaker, Role::Observer]),
1950 ..TypeOverride::default()
1951 },
1952 )]),
1953 ..PackImport::default()
1954 }),
1955 TypeDeclaration::Def(finding_def()),
1956 ];
1957 let registry = TypeRegistry::from_declarations(&declarations, &packs).unwrap();
1958
1959 assert_eq!(registry.len(), 6);
1960 assert!(registry.contains("reaction.create"));
1961 assert!(registry.contains("poll.create"));
1962 assert!(registry.contains("review.finding"));
1963 let vote = registry.get("poll.vote").unwrap();
1964 assert_eq!(
1965 vote.roles.as_deref(),
1966 Some([Role::Moderator, Role::Speaker, Role::Observer].as_slice())
1967 );
1968
1969 let subset = TypeDeclaration::Import(PackImport {
1970 use_pack: Some(pack_id::DELIBERATION.to_owned()),
1971 types: Some(vec!["poll.create".to_owned(), "poll.vote".to_owned()]),
1972 ..PackImport::default()
1973 });
1974 let registry = TypeRegistry::from_declarations(&[subset], &packs).unwrap();
1975 assert_eq!(registry.len(), 2);
1976 assert!(!registry.contains("question.create"));
1977 }
1978
1979 #[test]
1980 fn rejects_bad_pack_imports() {
1981 let packs = registered_packs();
1982 let unknown = TypeDeclaration::Import(PackImport {
1983 use_pack: Some("adp:unknown/1.0".to_owned()),
1984 ..PackImport::default()
1985 });
1986 assert!(TypeRegistry::from_declarations(&[unknown], &packs).is_err());
1987
1988 let bad_override = TypeDeclaration::Import(PackImport {
1989 use_pack: Some(pack_id::REACTIONS.to_owned()),
1990 overrides: BTreeMap::from([("poll.vote".to_owned(), TypeOverride::default())]),
1991 ..PackImport::default()
1992 });
1993 assert!(TypeRegistry::from_declarations(&[bad_override], &packs).is_err());
1994
1995 let both = PackImport {
1996 use_pack: Some(pack_id::REACTIONS.to_owned()),
1997 pack: Some("https://example.com/p.json".to_owned()),
1998 digest: Some("sha256:abc".to_owned()),
1999 ..PackImport::default()
2000 };
2001 assert!(validate_pack_import(&both).is_err());
2002 }
2003
2004 #[test]
2005 fn latest_type_definition_wins() {
2006 let mut registry = TypeRegistry::new();
2007 registry.define(finding_def()).unwrap();
2008 let mut redefined = finding_def();
2009 redefined.status = Some(TypeStatus::Disabled);
2010 registry.define(redefined).unwrap();
2011 assert_eq!(
2012 registry.get("review.finding").unwrap().status(),
2013 TypeStatus::Disabled
2014 );
2015 }
2016
2017 #[test]
2018 fn validates_custom_payloads_against_pack_schemas() {
2019 let packs = registered_packs();
2020 let registry = TypeRegistry::from_declarations(
2021 &[TypeDeclaration::Import(PackImport {
2022 use_pack: Some(pack_id::DELIBERATION.to_owned()),
2023 ..PackImport::default()
2024 })],
2025 &packs,
2026 )
2027 .unwrap();
2028
2029 let hash = "GDt8oHZQfQ3jl5ZUfyNxKZu07yAJdDYuaw_jf_JjLYs";
2030 registry
2031 .validate_payload(
2032 "poll.vote",
2033 &json!({"poll_event_id": hash, "option_ids": ["a"]}),
2034 )
2035 .unwrap();
2036 assert!(registry
2037 .validate_payload("poll.vote", &json!({"poll_event_id": hash}))
2038 .is_err());
2039 assert!(registry
2040 .validate_payload("turn.update", &json!({}))
2041 .is_err());
2042
2043 let mut disabled = TypeRegistry::new();
2044 let mut def = finding_def();
2045 def.status = Some(TypeStatus::Disabled);
2046 disabled.define(def).unwrap();
2047 assert!(disabled
2048 .validate_payload(
2049 "review.finding",
2050 &json!({"severity": "high", "summary": "s"})
2051 )
2052 .is_err());
2053 }
2054
2055 #[test]
2056 fn applies_kind_based_permissions() {
2057 let packs = registered_packs();
2058 let registry = TypeRegistry::from_declarations(
2059 &[
2060 TypeDeclaration::Import(PackImport {
2061 use_pack: Some(pack_id::REACTIONS.to_owned()),
2062 ..PackImport::default()
2063 }),
2064 TypeDeclaration::Import(PackImport {
2065 use_pack: Some(pack_id::DELIBERATION.to_owned()),
2066 overrides: BTreeMap::from([(
2067 "poll.vote".to_owned(),
2068 TypeOverride {
2069 roles: Some(vec![Role::Moderator, Role::Speaker, Role::Observer]),
2070 ..TypeOverride::default()
2071 },
2072 )]),
2073 ..PackImport::default()
2074 }),
2075 TypeDeclaration::Import(PackImport {
2076 use_pack: Some(pack_id::CURATION.to_owned()),
2077 ..PackImport::default()
2078 }),
2079 ],
2080 &packs,
2081 )
2082 .unwrap();
2083
2084 let observer = PermissionContext::for_role(Role::Observer);
2085 let speaker = PermissionContext::for_role(Role::Speaker);
2086 let moderator = PermissionContext::for_role(Role::Moderator);
2087 let creator = PermissionContext::creator(Some(Role::Observer));
2088
2089 assert!(can_submit_event("reaction.create", &observer, ®istry));
2091 assert!(can_submit_event("poll.vote", &observer, ®istry));
2093 assert!(can_submit_event("resource.add", &speaker, ®istry));
2095 assert!(!can_submit_event("resource.add", &observer, ®istry));
2096 assert!(can_submit_event("graph.update", &moderator, ®istry));
2098 assert!(!can_submit_event("graph.update", &speaker, ®istry));
2099 assert!(can_submit_event("graph.update", &creator, ®istry));
2101 assert!(can_submit_event(
2102 event_type::ROOM_CREATE,
2103 &PermissionContext::default(),
2104 ®istry
2105 ));
2106 assert!(can_submit_event(
2107 event_type::MESSAGE_CREATE,
2108 &creator,
2109 ®istry
2110 ));
2111 assert!(!can_submit_event("session.offer", &speaker, ®istry));
2113
2114 assert!(can_submit_event(
2116 event_type::ROOM_JOIN_REVIEW,
2117 &moderator,
2118 ®istry
2119 ));
2120 assert!(!can_submit_event(
2121 event_type::ROOM_JOIN_REVIEW,
2122 &speaker,
2123 ®istry
2124 ));
2125 assert!(can_submit_event(
2126 event_type::ROOM_MEMBER_ROLE_UPDATE,
2127 &moderator,
2128 ®istry
2129 ));
2130 assert!(can_submit_event(
2131 event_type::ROOM_CANCEL,
2132 &moderator,
2133 ®istry
2134 ));
2135 assert!(can_submit_event(
2136 event_type::TYPE_DEFINE,
2137 &moderator,
2138 ®istry
2139 ));
2140 assert!(!can_submit_event(
2141 event_type::TYPE_DEFINE,
2142 &speaker,
2143 ®istry
2144 ));
2145 assert!(can_submit_event(
2146 event_type::MESSAGE_CREATE,
2147 &speaker,
2148 ®istry
2149 ));
2150 assert!(!can_submit_event(
2151 event_type::MESSAGE_CREATE,
2152 &observer,
2153 ®istry
2154 ));
2155 assert!(can_submit_event(
2156 event_type::ROOM_LEAVE,
2157 &observer,
2158 ®istry
2159 ));
2160 assert!(!can_submit_event(
2161 event_type::ROOM_JOIN,
2162 &observer,
2163 ®istry
2164 ));
2165 let approved = PermissionContext {
2166 join_request_approved: true,
2167 ..PermissionContext::default()
2168 };
2169 assert!(can_submit_event(
2170 event_type::ROOM_JOIN,
2171 &approved,
2172 ®istry
2173 ));
2174 }
2175
2176 #[test]
2177 fn applies_state_restrictions() {
2178 let registry = TypeRegistry::new();
2179 let speaker = PermissionContext::for_role(Role::Speaker);
2180 let moderator = PermissionContext::for_role(Role::Moderator);
2181
2182 assert!(can_accept_room_write(
2183 event_type::MESSAGE_CREATE,
2184 RoomState::Active,
2185 &speaker,
2186 ®istry
2187 ));
2188 assert!(!can_accept_room_write(
2189 event_type::MESSAGE_CREATE,
2190 RoomState::Scheduled,
2191 &speaker,
2192 ®istry
2193 ));
2194 assert!(can_write_in_state(
2196 event_type::ROOM_JOIN_REVIEW,
2197 RoomState::Scheduled
2198 ));
2199 assert!(can_write_in_state(
2200 event_type::ROOM_MEMBER_ROLE_UPDATE,
2201 RoomState::Scheduled
2202 ));
2203 assert!(can_write_in_state(
2204 event_type::ROOM_LEAVE,
2205 RoomState::Scheduled
2206 ));
2207 assert!(can_write_in_state(
2208 event_type::TYPE_DEFINE,
2209 RoomState::Scheduled
2210 ));
2211 assert!(can_write_in_state(
2212 event_type::ROOM_CANCEL,
2213 RoomState::Scheduled
2214 ));
2215 assert!(!can_write_in_state(
2216 event_type::ROOM_CLOSE,
2217 RoomState::Scheduled
2218 ));
2219 assert!(can_accept_room_write(
2220 event_type::TYPE_DEFINE,
2221 RoomState::Scheduled,
2222 &moderator,
2223 ®istry
2224 ));
2225 assert!(!can_write_in_state("reaction.create", RoomState::Ended));
2227 assert!(!can_write_in_state(
2228 event_type::ROOM_LEAVE,
2229 RoomState::Ended
2230 ));
2231 assert!(!can_write_in_state(
2232 event_type::ROOM_JOIN,
2233 RoomState::Cancelled
2234 ));
2235 assert!(can_write_in_state(
2237 event_type::ROOM_CLOSE,
2238 RoomState::Active
2239 ));
2240 assert!(!can_write_in_state(
2241 event_type::ROOM_CANCEL,
2242 RoomState::Active
2243 ));
2244 }
2245
2246 #[test]
2247 fn validates_room_creation_payloads() {
2248 let mut payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
2249 payload.policy = Some(RoomPolicy {
2250 max_speakers: Some(2),
2251 ..RoomPolicy::default()
2252 });
2253 payload.guidance = Some("Cite sources.".to_owned());
2254 payload.types = vec![
2255 TypeDeclaration::Import(PackImport {
2256 use_pack: Some(pack_id::REACTIONS.to_owned()),
2257 ..PackImport::default()
2258 }),
2259 TypeDeclaration::Def(finding_def()),
2260 ];
2261 validate_room_create_payload(&payload).unwrap();
2262
2263 let empty_topic = RoomCreatePayload::new(" ", Visibility::Public, 1000, 2000);
2264 assert!(validate_room_create_payload(&empty_topic).is_err());
2265
2266 let invalid_time = RoomCreatePayload::new("Research room", Visibility::Public, 2000, 1000);
2267 assert!(validate_room_create_payload(&invalid_time).is_err());
2268
2269 let mut zero_speakers =
2270 RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
2271 zero_speakers.policy = Some(RoomPolicy {
2272 max_speakers: Some(0),
2273 ..RoomPolicy::default()
2274 });
2275 assert!(validate_room_create_payload(&zero_speakers).is_err());
2276
2277 let mut reserved = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
2278 let mut bad_def = finding_def();
2279 bad_def.name = "room.custom".to_owned();
2280 reserved.types = vec![TypeDeclaration::Def(bad_def)];
2281 assert!(validate_room_create_payload(&reserved).is_err());
2282 }
2283
2284 #[test]
2285 fn verifies_pack_digests() {
2286 let bytes = b"pack document bytes";
2287 let digest = format!("sha256:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)));
2288 verify_pack_digest(bytes, &digest).unwrap();
2289 let digest = format!(
2290 "sha3-256:{}",
2291 URL_SAFE_NO_PAD.encode(Sha3_256::digest(bytes))
2292 );
2293 verify_pack_digest(bytes, &digest).unwrap();
2294 assert!(verify_pack_digest(b"tampered", &digest).is_err());
2295 assert!(verify_pack_digest(bytes, "md5:abc").is_err());
2296 assert!(verify_pack_digest(bytes, "not-a-digest").is_err());
2297 }
2298
2299 #[test]
2300 fn hash_canonical_json_rejects_unserializable_values() {
2301 assert!(hash_canonical_json(&FailingPayload).is_err());
2302 }
2303
2304 #[test]
2305 fn builds_and_verifies_server_record_chains() {
2306 let signer = AgentSigner::from_seed([18; 32]);
2307 let envelope1 = signer
2308 .sign_event(Event::new(
2309 PROTOCOL,
2310 event_type::ROOM_CREATE,
2311 signer.agent_id(),
2312 100,
2313 1,
2314 json!({
2315 "topic": "Research room",
2316 "visibility": "public",
2317 "start_time": 1000,
2318 "end_time": 2000
2319 }),
2320 ))
2321 .unwrap();
2322 let record1 = build_server_record("room123", 1, None, 110, envelope1).unwrap();
2323
2324 let envelope2 = signer
2325 .sign_event(discourse_event(
2326 event_type::MESSAGE_CREATE,
2327 signer.agent_id(),
2328 120,
2329 2,
2330 "room123",
2331 1,
2332 record1.hash.clone(),
2333 json!({"content_type": "text/plain", "content": "hello"}),
2334 ))
2335 .unwrap();
2336 let record2 =
2337 build_server_record("room123", 2, Some(record1.hash.clone()), 130, envelope2).unwrap();
2338
2339 assert_eq!(
2340 record1.hash,
2341 server_record_hash("room123", 1, None, &record1.envelope.hash, 110).unwrap()
2342 );
2343 verify_server_record(&record1).unwrap();
2344 verify_server_record_chain(&[record1.clone(), record2.clone()]).unwrap();
2345 assert_eq!(
2346 archive_events_digest(&[record1.clone(), record2.clone()])
2347 .unwrap()
2348 .len(),
2349 43
2350 );
2351 assert!(verify_server_record_chain(&[record2]).is_err());
2352
2353 let broken = ServerRecord {
2354 pre_hash: Some("bad".to_owned()),
2355 ..record1
2356 };
2357 assert!(verify_server_record_chain(&[broken]).is_err());
2358 }
2359
2360 #[test]
2361 fn type_declaration_serde_round_trips() {
2362 let inline: TypeDeclaration = serde_json::from_value(json!({
2363 "type": "review.finding",
2364 "kind": "message",
2365 "title": "Review finding",
2366 "schema": {"type": "object"}
2367 }))
2368 .unwrap();
2369 assert_eq!(
2370 serde_json::to_value(&inline).unwrap()["type"],
2371 json!("review.finding")
2372 );
2373
2374 let import: TypeDeclaration = serde_json::from_value(json!({
2375 "use": "adp:reactions/1.0",
2376 "overrides": {"reaction.create": {"status": "deprecated"}}
2377 }))
2378 .unwrap();
2379 assert_eq!(
2380 serde_json::to_value(&import).unwrap()["use"],
2381 json!("adp:reactions/1.0")
2382 );
2383 validate_type_declaration(&import).unwrap();
2384 }
2385
2386 #[test]
2387 fn constructors_and_helpers() {
2388 assert_eq!(RoomPolicy::new(), RoomPolicy::default());
2389
2390 let input = RoomJoinRequestInput::new(Role::Observer);
2391 assert_eq!(input.role, Role::Observer);
2392 assert!(input.perspective.is_none());
2393
2394 assert_eq!(
2395 MessageCreatePayload::markdown("# title").content_type,
2396 "text/markdown"
2397 );
2398 assert_eq!(
2399 MessageCreatePayload::text("hi").content,
2400 Value::String("hi".to_owned())
2401 );
2402
2403 assert!(event_requires_room_id(event_type::MESSAGE_CREATE));
2404 assert!(!event_requires_room_id(event_type::ROOM_CREATE));
2405
2406 let signer = AgentSigner::from_seed([40; 32]);
2407 let event = type_define_event(
2408 signer.agent_id(),
2409 1,
2410 1,
2411 "room1",
2412 1,
2413 "room-head-hash",
2414 TypeDeclaration::Def(finding_def()),
2415 );
2416 assert_eq!(event.protocol, PROTOCOL);
2417 assert_eq!(event.kind, event_type::TYPE_DEFINE);
2418 assert_eq!(event.room_id.as_deref(), Some("room1"));
2419 }
2420
2421 #[test]
2422 fn validate_discourse_envelope_checks_protocol() {
2423 let signer = AgentSigner::from_seed([41; 32]);
2424 let event = Event::new(
2425 "wrong-protocol/1.0",
2426 event_type::MESSAGE_CREATE,
2427 signer.agent_id(),
2428 100,
2429 1,
2430 MessageCreatePayload::text("hi"),
2431 )
2432 .with_room_id("room1");
2433 let envelope = signer.sign_event(event).unwrap();
2434 assert!(validate_discourse_envelope(&envelope).is_err());
2435 }
2436
2437 #[test]
2438 fn validate_discourse_envelope_covers_payload_instantiations() {
2439 let signer = AgentSigner::from_seed([44; 32]);
2440 let room_payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
2441
2442 let mut invalid_room_create = signer
2443 .sign_event(room_create_event(
2444 signer.agent_id(),
2445 100,
2446 1,
2447 room_payload.clone(),
2448 ))
2449 .unwrap();
2450 invalid_room_create.signature = "invalid".to_owned();
2451 assert!(validate_discourse_envelope(&invalid_room_create).is_err());
2452
2453 let wrong_room_protocol = signer
2454 .sign_event(Event::new(
2455 "wrong-protocol/1.0",
2456 event_type::ROOM_CREATE,
2457 signer.agent_id(),
2458 100,
2459 2,
2460 room_payload.clone(),
2461 ))
2462 .unwrap();
2463 assert!(validate_discourse_envelope(&wrong_room_protocol).is_err());
2464
2465 let missing_room_id = signer
2466 .sign_event(Event::new(
2467 PROTOCOL,
2468 event_type::MESSAGE_CREATE,
2469 signer.agent_id(),
2470 100,
2471 3,
2472 room_payload.clone(),
2473 ))
2474 .unwrap();
2475 assert!(validate_discourse_envelope(&missing_room_id).is_err());
2476
2477 let room_payload_as_message = signer
2478 .sign_event(
2479 Event::new(
2480 PROTOCOL,
2481 event_type::MESSAGE_CREATE,
2482 signer.agent_id(),
2483 100,
2484 4,
2485 room_payload,
2486 )
2487 .with_room_id("room1")
2488 .with_room_head(1, "room-head-hash"),
2489 )
2490 .unwrap();
2491 validate_discourse_envelope(&room_payload_as_message).unwrap();
2492
2493 let message_payload_as_room = signer
2494 .sign_event(Event::new(
2495 PROTOCOL,
2496 event_type::ROOM_CREATE,
2497 signer.agent_id(),
2498 100,
2499 5,
2500 MessageCreatePayload::text("hi"),
2501 ))
2502 .unwrap();
2503 validate_discourse_envelope(&message_payload_as_room).unwrap();
2504 let message_payload_with_room = signer
2505 .sign_event(
2506 Event::new(
2507 PROTOCOL,
2508 event_type::ROOM_CREATE,
2509 signer.agent_id(),
2510 100,
2511 6,
2512 MessageCreatePayload::text("hi"),
2513 )
2514 .with_room_id("room1"),
2515 )
2516 .unwrap();
2517 assert!(validate_discourse_envelope(&message_payload_with_room).is_err());
2518
2519 let review_payload = join_review_payload(signer.agent_id());
2520 let mut invalid_review = signer
2521 .sign_event(discourse_event(
2522 event_type::ROOM_JOIN_REVIEW,
2523 signer.agent_id(),
2524 100,
2525 7,
2526 "room1",
2527 1,
2528 "room-head-hash",
2529 review_payload.clone(),
2530 ))
2531 .unwrap();
2532 invalid_review.signature = "invalid".to_owned();
2533 assert!(validate_discourse_envelope(&invalid_review).is_err());
2534
2535 let wrong_review_protocol = signer
2536 .sign_event(
2537 Event::new(
2538 "wrong-protocol/1.0",
2539 event_type::ROOM_JOIN_REVIEW,
2540 signer.agent_id(),
2541 100,
2542 8,
2543 review_payload.clone(),
2544 )
2545 .with_room_id("room1"),
2546 )
2547 .unwrap();
2548 assert!(validate_discourse_envelope(&wrong_review_protocol).is_err());
2549
2550 let missing_review_room = signer
2551 .sign_event(Event::new(
2552 PROTOCOL,
2553 event_type::ROOM_JOIN_REVIEW,
2554 signer.agent_id(),
2555 100,
2556 9,
2557 review_payload.clone(),
2558 ))
2559 .unwrap();
2560 assert!(validate_discourse_envelope(&missing_review_room).is_err());
2561
2562 let review_payload_as_room = signer
2563 .sign_event(Event::new(
2564 PROTOCOL,
2565 event_type::ROOM_CREATE,
2566 signer.agent_id(),
2567 100,
2568 10,
2569 review_payload,
2570 ))
2571 .unwrap();
2572 validate_discourse_envelope(&review_payload_as_room).unwrap();
2573 }
2574
2575 #[test]
2576 fn validate_room_path_matches_and_rejects() {
2577 let signer = AgentSigner::from_seed([42; 32]);
2578 let in_room = signer
2579 .sign_event(discourse_event(
2580 event_type::MESSAGE_CREATE,
2581 signer.agent_id(),
2582 100,
2583 1,
2584 "room1",
2585 1,
2586 "room-head-hash",
2587 MessageCreatePayload::text("hi"),
2588 ))
2589 .unwrap();
2590 validate_room_path(&in_room, "room1").unwrap();
2591 assert!(validate_room_path(&in_room, "room2").is_err());
2592
2593 let no_room = signer
2594 .sign_event(Event::new(
2595 PROTOCOL,
2596 event_type::MESSAGE_CREATE,
2597 signer.agent_id(),
2598 100,
2599 1,
2600 MessageCreatePayload::text("hi"),
2601 ))
2602 .unwrap();
2603 assert!(validate_room_path(&no_room, "room1").is_err());
2604 }
2605
2606 #[test]
2607 fn validate_room_path_covers_payload_instantiations() {
2608 let signer = AgentSigner::from_seed([45; 32]);
2609 let room_payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
2610 let in_room = signer
2611 .sign_event(
2612 Event::new(
2613 PROTOCOL,
2614 event_type::MESSAGE_CREATE,
2615 signer.agent_id(),
2616 100,
2617 1,
2618 room_payload.clone(),
2619 )
2620 .with_room_id("room1")
2621 .with_room_head(1, "room-head-hash"),
2622 )
2623 .unwrap();
2624 validate_room_path(&in_room, "room1").unwrap();
2625 assert!(validate_room_path(&in_room, "room2").is_err());
2626
2627 let missing_room = signer
2628 .sign_event(Event::new(
2629 PROTOCOL,
2630 event_type::MESSAGE_CREATE,
2631 signer.agent_id(),
2632 100,
2633 2,
2634 room_payload,
2635 ))
2636 .unwrap();
2637 assert!(validate_room_path(&missing_room, "room1").is_err());
2638
2639 let message_room_create = signer
2640 .sign_event(Event::new(
2641 PROTOCOL,
2642 event_type::ROOM_CREATE,
2643 signer.agent_id(),
2644 100,
2645 3,
2646 MessageCreatePayload::text("hi"),
2647 ))
2648 .unwrap();
2649 validate_room_path(&message_room_create, "room1").unwrap();
2650
2651 let message_room_create_with_room = signer
2652 .sign_event(
2653 Event::new(
2654 PROTOCOL,
2655 event_type::ROOM_CREATE,
2656 signer.agent_id(),
2657 100,
2658 4,
2659 MessageCreatePayload::text("hi"),
2660 )
2661 .with_room_id("room1"),
2662 )
2663 .unwrap();
2664 assert!(validate_room_path(&message_room_create_with_room, "room1").is_err());
2665 }
2666
2667 #[test]
2668 fn validate_type_def_rejects_bad_shapes() {
2669 let mut empty_title = finding_def();
2670 empty_title.title = " ".to_owned();
2671 assert!(validate_type_def(&empty_title).is_err());
2672 assert!(TypeRegistry::new().define(empty_title).is_err());
2673
2674 let mut not_object = finding_def();
2675 not_object.schema = json!("nope");
2676 assert!(validate_type_def(¬_object).is_err());
2677
2678 let mut invalid_schema = finding_def();
2679 invalid_schema.schema = json!({"type": 123});
2680 assert!(validate_type_def(&invalid_schema).is_err());
2681
2682 let mut empty_roles = finding_def();
2683 empty_roles.roles = Some(vec![]);
2684 assert!(validate_type_def(&empty_roles).is_err());
2685
2686 let mut zero_rate = finding_def();
2687 zero_rate.rate_hint = Some(0);
2688 assert!(validate_type_def(&zero_rate).is_err());
2689
2690 let mut zero_payload = finding_def();
2691 zero_payload.max_payload_hint = Some(0);
2692 assert!(validate_type_def(&zero_payload).is_err());
2693 }
2694
2695 #[test]
2696 fn validate_pack_import_covers_each_arm() {
2697 validate_pack_import(&PackImport {
2698 use_pack: Some(pack_id::REACTIONS.to_owned()),
2699 ..PackImport::default()
2700 })
2701 .unwrap();
2702
2703 validate_pack_import(&PackImport {
2704 pack: Some("https://example.com/p.json".to_owned()),
2705 digest: Some("sha256:abc".to_owned()),
2706 ..PackImport::default()
2707 })
2708 .unwrap();
2709
2710 assert!(validate_pack_import(&PackImport {
2711 pack: Some("https://example.com/p.json".to_owned()),
2712 digest: Some(" ".to_owned()),
2713 ..PackImport::default()
2714 })
2715 .is_err());
2716
2717 assert!(validate_pack_import(&PackImport::default()).is_err());
2718
2719 assert!(validate_pack_import(&PackImport {
2720 use_pack: Some(pack_id::REACTIONS.to_owned()),
2721 types: Some(vec![]),
2722 ..PackImport::default()
2723 })
2724 .is_err());
2725
2726 let invalid = TypeDeclaration::Import(PackImport {
2727 use_pack: Some(pack_id::REACTIONS.to_owned()),
2728 pack: Some("https://example.com/p.json".to_owned()),
2729 digest: Some("sha256:abc".to_owned()),
2730 ..PackImport::default()
2731 });
2732 assert!(TypeRegistry::from_declarations(&[invalid], ®istered_packs()).is_err());
2733 }
2734
2735 #[test]
2736 fn registered_pack_id_shape() {
2737 assert!(validate_pack_import(&PackImport {
2738 use_pack: Some("not-prefixed".to_owned()),
2739 ..PackImport::default()
2740 })
2741 .is_err());
2742 assert!(validate_pack_import(&PackImport {
2743 use_pack: Some("adp:no-slash".to_owned()),
2744 ..PackImport::default()
2745 })
2746 .is_err());
2747 assert!(validate_pack_import(&PackImport {
2748 use_pack: Some("adp:bad_name/1.0".to_owned()),
2749 ..PackImport::default()
2750 })
2751 .is_err());
2752 assert!(validate_pack_import(&PackImport {
2753 use_pack: Some("adp:bad/1.x".to_owned()),
2754 ..PackImport::default()
2755 })
2756 .is_err());
2757 assert!(validate_pack_import(&PackImport {
2758 use_pack: Some("adp:bad/1.2.3".to_owned()),
2759 ..PackImport::default()
2760 })
2761 .is_err());
2762 }
2763
2764 #[test]
2765 fn validate_message_create_payload_checks_content_type() {
2766 validate_message_create_payload(&MessageCreatePayload::text("hi")).unwrap();
2767 let mut empty = MessageCreatePayload::text("hi");
2768 empty.content_type = " ".to_owned();
2769 assert!(validate_message_create_payload(&empty).is_err());
2770 }
2771
2772 #[test]
2773 fn import_rejects_unknown_subset_and_applies_all_overrides() {
2774 let packs = registered_packs();
2775 let missing_subset = TypeDeclaration::Import(PackImport {
2776 use_pack: Some(pack_id::REACTIONS.to_owned()),
2777 types: Some(vec!["does.not.exist".to_owned()]),
2778 ..PackImport::default()
2779 });
2780 assert!(TypeRegistry::from_declarations(&[missing_subset], &packs).is_err());
2781
2782 let missing_pack = TypeDeclaration::Import(PackImport {
2783 use_pack: Some("adp:missing/1.0".to_owned()),
2784 ..PackImport::default()
2785 });
2786 assert!(TypeRegistry::from_declarations(&[missing_pack], &packs).is_err());
2787
2788 let hidden_override = TypeDeclaration::Import(PackImport {
2789 use_pack: Some(pack_id::DELIBERATION.to_owned()),
2790 types: Some(vec!["poll.create".to_owned()]),
2791 overrides: BTreeMap::from([("poll.vote".to_owned(), TypeOverride::default())]),
2792 ..PackImport::default()
2793 });
2794 assert!(TypeRegistry::from_declarations(&[hidden_override], &packs).is_err());
2795
2796 let empty_pack = BTreeMap::from([(
2797 "adp:empty/1.0".to_owned(),
2798 Pack {
2799 id: "adp:empty/1.0".to_owned(),
2800 title: "Empty pack".to_owned(),
2801 description: None,
2802 types: Vec::new(),
2803 extra: BTreeMap::new(),
2804 },
2805 )]);
2806 let empty_import = TypeDeclaration::Import(PackImport {
2807 use_pack: Some("adp:empty/1.0".to_owned()),
2808 ..PackImport::default()
2809 });
2810 assert!(
2811 TypeRegistry::from_declarations(&[empty_import], &empty_pack)
2812 .unwrap()
2813 .is_empty()
2814 );
2815
2816 let overridden = TypeDeclaration::Import(PackImport {
2817 use_pack: Some(pack_id::REACTIONS.to_owned()),
2818 overrides: BTreeMap::from([(
2819 "reaction.create".to_owned(),
2820 TypeOverride {
2821 roles: Some(vec![Role::Moderator]),
2822 instructions: Some("be concise".to_owned()),
2823 status: Some(TypeStatus::Deprecated),
2824 rate_hint: Some(5),
2825 max_payload_hint: Some(2048),
2826 },
2827 )]),
2828 ..PackImport::default()
2829 });
2830 let registry = TypeRegistry::from_declarations(&[overridden], &packs).unwrap();
2831 let def = registry.get("reaction.create").unwrap();
2832 assert_eq!(def.instructions.as_deref(), Some("be concise"));
2833 assert_eq!(def.status(), TypeStatus::Deprecated);
2834 assert_eq!(def.rate_hint, Some(5));
2835 assert_eq!(def.max_payload_hint, Some(2048));
2836 assert_eq!(def.roles.as_deref(), Some([Role::Moderator].as_slice()));
2837
2838 let no_roles_override = TypeDeclaration::Import(PackImport {
2839 use_pack: Some(pack_id::REACTIONS.to_owned()),
2840 overrides: BTreeMap::from([(
2841 "reaction.create".to_owned(),
2842 TypeOverride {
2843 instructions: Some("no role override".to_owned()),
2844 ..TypeOverride::default()
2845 },
2846 )]),
2847 ..PackImport::default()
2848 });
2849 let registry = TypeRegistry::from_declarations(&[no_roles_override], &packs).unwrap();
2850 let def = registry.get("reaction.create").unwrap();
2851 assert_eq!(def.instructions.as_deref(), Some("no role override"));
2852 assert!(def.roles.is_none());
2853
2854 let mut invalid_def = finding_def();
2855 invalid_def.title = String::new();
2856 let invalid_pack = BTreeMap::from([(
2857 "adp:invalid/1.0".to_owned(),
2858 Pack {
2859 id: "adp:invalid/1.0".to_owned(),
2860 title: "Invalid pack".to_owned(),
2861 description: None,
2862 types: vec![invalid_def],
2863 extra: BTreeMap::new(),
2864 },
2865 )]);
2866 let invalid_import = TypeDeclaration::Import(PackImport {
2867 use_pack: Some("adp:invalid/1.0".to_owned()),
2868 ..PackImport::default()
2869 });
2870 assert!(TypeRegistry::from_declarations(&[invalid_import], &invalid_pack).is_err());
2871 }
2872
2873 #[test]
2874 fn registry_introspection_and_registry_validation() {
2875 let mut registry = TypeRegistry::new();
2876 assert!(registry.is_empty());
2877 registry.define(finding_def()).unwrap();
2878 assert!(!registry.is_empty());
2879 assert_eq!(registry.definitions().count(), 1);
2880
2881 validate_event_against_registry(
2883 event_type::MESSAGE_CREATE,
2884 &json!({"anything": true}),
2885 ®istry,
2886 )
2887 .unwrap();
2888 validate_event_against_registry(
2889 "review.finding",
2890 &json!({"severity": "high", "summary": "s"}),
2891 ®istry,
2892 )
2893 .unwrap();
2894 assert!(validate_event_against_registry("review.finding", &json!({}), ®istry).is_err());
2895
2896 let mut invalid_schema = finding_def();
2897 invalid_schema.schema = json!({"type": 123});
2898 registry
2899 .types
2900 .insert(invalid_schema.name.clone(), invalid_schema);
2901 assert!(registry
2902 .validate_payload(
2903 "review.finding",
2904 &json!({"severity": "high", "summary": "s"})
2905 )
2906 .is_err());
2907 }
2908
2909 #[test]
2910 fn custom_permission_edges_and_room_write() {
2911 let mut registry = TypeRegistry::new();
2912 registry.define(finding_def()).unwrap();
2913 let mut disabled = finding_def();
2914 disabled.name = "review.disabled".to_owned();
2915 disabled.status = Some(TypeStatus::Disabled);
2916 registry.define(disabled).unwrap();
2917
2918 assert!(!can_submit_event(
2920 "review.disabled",
2921 &PermissionContext::creator(None),
2922 ®istry
2923 ));
2924 assert!(!can_submit_event(
2926 "review.finding",
2927 &PermissionContext::default(),
2928 ®istry
2929 ));
2930
2931 validate_room_write(
2932 event_type::MESSAGE_CREATE,
2933 RoomState::Active,
2934 &PermissionContext::for_role(Role::Speaker),
2935 ®istry,
2936 )
2937 .unwrap();
2938 assert!(validate_room_write(
2939 event_type::MESSAGE_CREATE,
2940 RoomState::Ended,
2941 &PermissionContext::for_role(Role::Speaker),
2942 ®istry,
2943 )
2944 .is_err());
2945 }
2946
2947 #[test]
2948 fn server_record_chain_violations() {
2949 let signer = AgentSigner::from_seed([43; 32]);
2950 let make = |seq: u64, nonce: u64, pre_hash: Option<String>| {
2951 let envelope = signer
2952 .sign_event(discourse_event(
2953 event_type::MESSAGE_CREATE,
2954 signer.agent_id(),
2955 100,
2956 nonce,
2957 "room1",
2958 1,
2959 "room-head-hash",
2960 json!({"content_type": "text/plain", "content": "hi"}),
2961 ))
2962 .unwrap();
2963 build_server_record("room1", seq, pre_hash, 100 + seq as i64, envelope).unwrap()
2964 };
2965
2966 let first = make(1, 1, None);
2967 let gap = make(3, 2, Some(first.hash.clone()));
2968 assert!(verify_server_record_chain(&[first.clone(), gap]).is_err());
2969
2970 let wrong_pre = make(2, 3, Some("not-the-previous-hash".to_owned()));
2971 assert!(verify_server_record_chain(&[first.clone(), wrong_pre]).is_err());
2972
2973 let first_with_pre = make(1, 4, Some("unexpected".to_owned()));
2974 assert!(verify_server_record_chain(&[first_with_pre]).is_err());
2975 }
2976}