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