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