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