Skip to main content

agent_protocols/
discourse.rs

1//! Agent Discourse Protocol 1.0: kernel types, the room type system, and
2//! verification helpers.
3//!
4//! The protocol defines nine built-in event types. Every other event type is
5//! declared per room as a schema-validated type definition, either inline or
6//! imported from a type pack. Hosts validate structure and permissions; they
7//! never need to understand application semantics.
8
9use 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
23/// The nine built-in event types. All other types are room-defined.
24pub 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
48/// Custom event types must not use these prefixes.
49pub const RESERVED_TYPE_PREFIXES: [&str; 2] = ["room.", "type."];
50
51/// Registered type packs defined by the specification in `1.0.packs.json`.
52pub 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/// Permission class of an event type.
88#[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/// A room-scoped declaration of a custom event type.
184#[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    /// Self-contained JSON Schema (draft 2020-12) for the event payload.
193    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/// Per-type adjustments applied when importing a pack.
217#[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/// Imports a registered pack (`use`) or an external pack (`pack` + `digest`).
232#[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/// One entry of `room.create.payload.types` or a `type.define` payload.
247#[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/// The shape of `1.0.packs.json` and externally published pack documents.
266#[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
274/// Indexes the packs of a document by pack id for registry materialization.
275pub 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    /// Materialized type registry served by the host.
327    #[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    /// Latest accepted non-signal record. Falls back to `seq`/`hash` on older hosts.
335    #[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/// Shared payload of `room.leave`, `room.close`, and `room.cancel`.
495#[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
725/// Checks the shape of a custom event type name: lowercase dot-separated,
726/// at least two segments, not built-in, not under a reserved prefix.
727pub 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/// The effective set of type definitions active in a room.
883#[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    /// Materializes a registry from declarations, resolving pack imports from
894    /// `packs`, keyed by registered pack id or external pack URI.
895    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    /// Applies one declaration: an inline definition or a pack import.
907    /// Redefining an existing type replaces it; the latest definition wins.
908    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    /// Validates a custom event payload against the type's schema and status.
1007    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
1024/// Validates an event payload: built-in payloads are accepted as-is (use the
1025/// typed validators for them); custom payloads must satisfy the registry.
1026pub 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
1044/// Verifies a `<algorithm>:<base64url-digest>` content digest over raw bytes.
1045/// Supports `sha256` and `sha3-256`.
1046pub 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/// Permission inputs for one actor in one room.
1182#[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
1207/// Default sender roles for each kind. The creator passes every role check.
1208pub 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
1216/// Role check for one event type, using kind defaults and per-type overrides
1217/// from the room's type registry. State checks are separate.
1218pub 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        // signal kind: all members, including observers
1691        assert!(can_submit_event("reaction.create", &observer, &registry));
1692        // poll.vote default excludes observers, but this room overrode roles
1693        assert!(can_submit_event("poll.vote", &observer, &registry));
1694        // message kind: speakers and moderators only
1695        assert!(can_submit_event("resource.add", &speaker, &registry));
1696        assert!(!can_submit_event("resource.add", &observer, &registry));
1697        // control kind: moderators only
1698        assert!(can_submit_event("graph.update", &moderator, &registry));
1699        assert!(!can_submit_event("graph.update", &speaker, &registry));
1700        // creator passes every role check regardless of current role
1701        assert!(can_submit_event("graph.update", &creator, &registry));
1702        assert!(can_submit_event(
1703            event_type::ROOM_CREATE,
1704            &PermissionContext::default(),
1705            &registry
1706        ));
1707        assert!(can_submit_event(
1708            event_type::MESSAGE_CREATE,
1709            &creator,
1710            &registry
1711        ));
1712        // undefined types are rejected
1713        assert!(!can_submit_event("session.offer", &speaker, &registry));
1714
1715        // built-in lifecycle rules
1716        assert!(can_submit_event(
1717            event_type::ROOM_JOIN_REVIEW,
1718            &moderator,
1719            &registry
1720        ));
1721        assert!(!can_submit_event(
1722            event_type::ROOM_JOIN_REVIEW,
1723            &speaker,
1724            &registry
1725        ));
1726        assert!(can_submit_event(
1727            event_type::ROOM_MEMBER_ROLE_UPDATE,
1728            &moderator,
1729            &registry
1730        ));
1731        assert!(can_submit_event(
1732            event_type::ROOM_CANCEL,
1733            &moderator,
1734            &registry
1735        ));
1736        assert!(can_submit_event(
1737            event_type::TYPE_DEFINE,
1738            &moderator,
1739            &registry
1740        ));
1741        assert!(!can_submit_event(
1742            event_type::TYPE_DEFINE,
1743            &speaker,
1744            &registry
1745        ));
1746        assert!(can_submit_event(
1747            event_type::MESSAGE_CREATE,
1748            &speaker,
1749            &registry
1750        ));
1751        assert!(!can_submit_event(
1752            event_type::MESSAGE_CREATE,
1753            &observer,
1754            &registry
1755        ));
1756        assert!(can_submit_event(
1757            event_type::ROOM_LEAVE,
1758            &observer,
1759            &registry
1760        ));
1761        assert!(!can_submit_event(
1762            event_type::ROOM_JOIN,
1763            &observer,
1764            &registry
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            &registry
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            &registry
1788        ));
1789        assert!(!can_accept_room_write(
1790            event_type::MESSAGE_CREATE,
1791            RoomState::Scheduled,
1792            &speaker,
1793            &registry
1794        ));
1795        // scheduled allows pre-start setup: reviews, role updates, leave, type.define
1796        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            &registry
1825        ));
1826        // ended rooms are strictly read-only
1827        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        // cancel only while scheduled, close only while active
1837        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(&not_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], &registered_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        // built-in payloads bypass the registry
2483        validate_event_against_registry(
2484            event_type::MESSAGE_CREATE,
2485            &json!({"anything": true}),
2486            &registry,
2487        )
2488        .unwrap();
2489        validate_event_against_registry(
2490            "review.finding",
2491            &json!({"severity": "high", "summary": "s"}),
2492            &registry,
2493        )
2494        .unwrap();
2495        assert!(validate_event_against_registry("review.finding", &json!({}), &registry).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        // disabled custom type is never submittable
2520        assert!(!can_submit_event(
2521            "review.disabled",
2522            &PermissionContext::creator(None),
2523            &registry
2524        ));
2525        // a member with no role cannot submit a custom type
2526        assert!(!can_submit_event(
2527            "review.finding",
2528            &PermissionContext::default(),
2529            &registry
2530        ));
2531
2532        validate_room_write(
2533            event_type::MESSAGE_CREATE,
2534            RoomState::Active,
2535            &PermissionContext::for_role(Role::Speaker),
2536            &registry,
2537        )
2538        .unwrap();
2539        assert!(validate_room_write(
2540            event_type::MESSAGE_CREATE,
2541            RoomState::Ended,
2542            &PermissionContext::for_role(Role::Speaker),
2543            &registry,
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}