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 twelve 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::{
20    validate_event_fields, validate_origin, verify_envelope, AgentId, Envelope, Event,
21    ListResponse, MAX_SAFE_NONCE,
22};
23
24pub const PROTOCOL: &str = "agent-discourse/1.0";
25
26/// The twelve built-in event types. All other types are room-defined.
27pub mod event_type {
28    pub const ROOM_CREATE: &str = "room.create";
29    pub const ROOM_UPDATE: &str = "room.update";
30    pub const ROOM_JOIN: &str = "room.join";
31    pub const ROOM_JOIN_REQUEST: &str = "room.join.request";
32    pub const ROOM_JOIN_REVIEW: &str = "room.join.review";
33    pub const ROOM_LEAVE: &str = "room.leave";
34    pub const ROOM_MEMBER_ROLE_UPDATE: &str = "room.member.role.update";
35    pub const ROOM_MEMBER_REMOVE: &str = "room.member.remove";
36    pub const ROOM_CLOSE: &str = "room.close";
37    pub const ROOM_CANCEL: &str = "room.cancel";
38    pub const TYPE_DEFINE: &str = "type.define";
39    pub const MESSAGE_CREATE: &str = "message.create";
40}
41
42pub const BUILTIN_EVENT_TYPES: [&str; 12] = [
43    event_type::ROOM_CREATE,
44    event_type::ROOM_UPDATE,
45    event_type::ROOM_JOIN,
46    event_type::ROOM_JOIN_REQUEST,
47    event_type::ROOM_JOIN_REVIEW,
48    event_type::ROOM_LEAVE,
49    event_type::ROOM_MEMBER_ROLE_UPDATE,
50    event_type::ROOM_MEMBER_REMOVE,
51    event_type::ROOM_CLOSE,
52    event_type::ROOM_CANCEL,
53    event_type::TYPE_DEFINE,
54    event_type::MESSAGE_CREATE,
55];
56
57/// Built-in membership events. They are `signal`-class: they anchor to an
58/// accepted record but are never checked against or advance the room head, so
59/// busy rooms cannot starve joins, reviews, or other membership writes.
60pub const MEMBERSHIP_EVENT_TYPES: [&str; 5] = [
61    event_type::ROOM_JOIN,
62    event_type::ROOM_JOIN_REVIEW,
63    event_type::ROOM_LEAVE,
64    event_type::ROOM_MEMBER_ROLE_UPDATE,
65    event_type::ROOM_MEMBER_REMOVE,
66];
67
68/// Contract writes (Section 5.1): anchored like signals, so discussion traffic
69/// cannot starve them, but head-advancing, so messages and control writes
70/// composed against the old contract are rejected and re-read.
71pub const CONTRACT_EVENT_TYPES: [&str; 4] = [
72    event_type::ROOM_UPDATE,
73    event_type::ROOM_CLOSE,
74    event_type::ROOM_CANCEL,
75    event_type::TYPE_DEFINE,
76];
77
78/// ADP-specific error codes (Section 19); shared codes come from Agent Identity.
79pub const DISCOURSE_ERROR_CODES: [&str; 19] = [
80    "room_not_found",
81    "room_not_active",
82    "host_mismatch",
83    "approval_required",
84    "join_request_not_found",
85    "join_request_not_pending",
86    "member_banned",
87    "role_not_allowed",
88    "max_speakers_exceeded",
89    "membership_required",
90    "room_head_mismatch",
91    "base_record_mismatch",
92    "agent_status_not_found",
93    "type_not_defined",
94    "type_disabled",
95    "type_conflict",
96    "invalid_type_schema",
97    "payload_schema_violation",
98    "pack_unavailable",
99];
100
101/// Hosts MUST reject events with more than this many `mentions` entries.
102pub const MAX_MENTIONS: usize = 32;
103
104/// Custom event types must not use these prefixes.
105pub const RESERVED_TYPE_PREFIXES: [&str; 3] = ["room.", "type.", "message."];
106
107/// Registered type packs defined by the specification in `1.0.packs.json`.
108pub mod pack_id {
109    pub const REACTIONS: &str = "adp:reactions/1.0";
110    pub const DELIBERATION: &str = "adp:deliberation/1.0";
111    pub const CURATION: &str = "adp:curation/1.0";
112    pub const MODERATION: &str = "adp:moderation/1.0";
113    pub const REALTIME: &str = "adp:realtime/1.0";
114
115    pub const REGISTERED: [&str; 5] = [REACTIONS, DELIBERATION, CURATION, MODERATION, REALTIME];
116}
117
118#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
119#[serde(rename_all = "snake_case")]
120pub enum RoomState {
121    Scheduled,
122    Active,
123    Ended,
124    Cancelled,
125}
126
127#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
128#[serde(rename_all = "snake_case")]
129pub enum Visibility {
130    Public,
131    Restricted,
132    Private,
133}
134
135#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
136#[serde(rename_all = "snake_case")]
137pub enum Role {
138    Moderator,
139    Speaker,
140    Observer,
141}
142
143/// Permission class of an event type.
144#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
145#[serde(rename_all = "snake_case")]
146pub enum TypeKind {
147    Message,
148    Signal,
149    Control,
150}
151
152#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(rename_all = "snake_case")]
154pub enum TypeStatus {
155    Active,
156    Deprecated,
157    Disabled,
158}
159
160/// Freshness class of an accepted record (Section 5.1): `genesis` and
161/// `contract` for the room lifecycle built-ins, `signal` for membership
162/// built-ins, `message` for `message.create`, and the registry `kind` for
163/// custom types. `message` and `control` writes must be based at or after the
164/// room head; `genesis`, `contract`, and `control` records advance it.
165#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
166#[serde(rename_all = "snake_case")]
167pub enum RecordClass {
168    Genesis,
169    Contract,
170    Message,
171    Signal,
172    Control,
173}
174
175/// Class of a built-in type per the Section 12.2 table; `None` for
176/// room-defined types and for `room.join.request`, which never becomes a record.
177pub fn builtin_event_class(event_type: &str) -> Option<RecordClass> {
178    match event_type {
179        event_type::ROOM_CREATE => Some(RecordClass::Genesis),
180        event_type::ROOM_UPDATE
181        | event_type::ROOM_CLOSE
182        | event_type::ROOM_CANCEL
183        | event_type::TYPE_DEFINE => Some(RecordClass::Contract),
184        event_type::ROOM_JOIN
185        | event_type::ROOM_JOIN_REVIEW
186        | event_type::ROOM_LEAVE
187        | event_type::ROOM_MEMBER_ROLE_UPDATE
188        | event_type::ROOM_MEMBER_REMOVE => Some(RecordClass::Signal),
189        event_type::MESSAGE_CREATE => Some(RecordClass::Message),
190        _ => None,
191    }
192}
193
194fn kind_class(kind: TypeKind) -> RecordClass {
195    match kind {
196        TypeKind::Message => RecordClass::Message,
197        TypeKind::Signal => RecordClass::Signal,
198        TypeKind::Control => RecordClass::Control,
199    }
200}
201
202/// Record class of an event type; `None` for `room.join.request` and for
203/// custom types absent from `types`.
204pub fn record_class(event_type: &str, types: &[TypeDef]) -> Option<RecordClass> {
205    if let Some(class) = builtin_event_class(event_type) {
206        return Some(class);
207    }
208    if is_builtin_event_type(event_type) {
209        return None;
210    }
211    types
212        .iter()
213        .find(|def| def.name == event_type)
214        .map(|def| kind_class(def.kind))
215}
216
217/// Whether an accepted record of this type advances the room head (Section
218/// 5.1): `genesis`, `contract`, and `control` records. Unknown custom types
219/// default to head-advancing.
220pub fn event_advances_room_head(event_type: &str, registry: &TypeRegistry) -> bool {
221    event_type_advances_head(
222        event_type,
223        &registry.definitions().cloned().collect::<Vec<_>>(),
224    )
225}
226
227/// [`event_advances_room_head`] over a materialized type list.
228pub fn event_type_advances_head(event_type: &str, types: &[TypeDef]) -> bool {
229    !matches!(
230        record_class(event_type, types),
231        Some(RecordClass::Message | RecordClass::Signal)
232    )
233}
234
235/// Whether a write of this type is checked against the room head (Section
236/// 5.1): `message.create` and custom `message`/`control` kinds must be based
237/// at or after the current head. Contract and signal writes only anchor.
238/// Unknown custom types default to head-checked.
239pub fn event_requires_room_head(event_type: &str, types: &[TypeDef]) -> bool {
240    match record_class(event_type, types) {
241        Some(RecordClass::Message | RecordClass::Control) => true,
242        Some(_) => false,
243        None => !is_builtin_event_type(event_type),
244    }
245}
246
247/// Section 5.1 base check for a room write based on `base_seq` / `base_hash`.
248/// `anchor_hash` is the hash of the accepted record at `base_seq` in the same
249/// room (`None` when there is none) and `head_seq` is the current room head.
250/// Every base must name an accepted record (`base_record_mismatch`);
251/// `message` and `control` writes must also be based at or after the head
252/// (`room_head_mismatch`).
253pub fn validate_room_base(
254    event_type: &str,
255    types: &[TypeDef],
256    base_seq: u64,
257    base_hash: &str,
258    anchor_hash: Option<&str>,
259    head_seq: u64,
260) -> Result<()> {
261    if anchor_hash != Some(base_hash) {
262        return Err(SdkError::protocol(
263            "base_record_mismatch",
264            format!("base {base_seq} does not name an accepted record of this room"),
265        ));
266    }
267    if event_requires_room_head(event_type, types) && base_seq < head_seq {
268        return Err(SdkError::protocol(
269            "room_head_mismatch",
270            format!("base {base_seq} is before the room head {head_seq}"),
271        ));
272    }
273    Ok(())
274}
275
276#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
277#[serde(rename_all = "snake_case")]
278pub enum JoinRequestStatus {
279    Pending,
280    Approved,
281    Rejected,
282    Expired,
283}
284
285#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
286#[serde(rename_all = "snake_case")]
287pub enum JoinDecision {
288    Approve,
289    Reject,
290}
291
292#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
293pub struct RoomCreatePayload {
294    /// Origin of the host API the room is created on; binds the event to one host.
295    pub host: String,
296    pub topic: String,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub agenda: Option<String>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub guidance: Option<String>,
301    pub visibility: Visibility,
302    pub start_time: i64,
303    pub end_time: i64,
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub tags: Option<Vec<String>>,
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub language: Option<String>,
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub policy: Option<RoomPolicy>,
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub types: Option<Vec<TypeDeclaration>>,
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub extra: Option<BTreeMap<String, Value>>,
314}
315
316impl RoomCreatePayload {
317    pub fn new(
318        host: impl Into<String>,
319        topic: impl Into<String>,
320        visibility: Visibility,
321        start_time: i64,
322        end_time: i64,
323    ) -> Self {
324        Self {
325            host: host.into(),
326            topic: topic.into(),
327            agenda: None,
328            guidance: None,
329            visibility,
330            start_time,
331            end_time,
332            tags: None,
333            language: None,
334            policy: None,
335            types: None,
336            extra: None,
337        }
338    }
339}
340
341#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
342#[serde(deny_unknown_fields)]
343pub struct RoomPolicy {
344    /// Agent IDs pre-approved for direct `room.join` with exactly this role.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub invites: Option<BTreeMap<AgentId, Role>>,
347    /// Roles anyone may take by direct `room.join`; see [`effective_open_roles`].
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub open_roles: Option<Vec<Role>>,
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub max_speakers: Option<u32>,
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub observer_allowed: Option<bool>,
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub extra: Option<BTreeMap<String, Value>>,
356}
357
358impl RoomPolicy {
359    pub fn new() -> Self {
360        Self::default()
361    }
362}
363
364/// Payload of `room.update`: a partial contract revision. A present field
365/// replaces the current value entirely; an empty value clears an optional
366/// field. `host` and `visibility` are not updatable, and the type registry
367/// evolves only through `type.define`. The field set is closed:
368/// `deny_unknown_fields` rejects non-updatable fields so all three SDKs agree.
369#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
370#[serde(deny_unknown_fields)]
371pub struct RoomUpdatePayload {
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub topic: Option<String>,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub agenda: Option<String>,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub guidance: Option<String>,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub tags: Option<Vec<String>>,
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub language: Option<String>,
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub policy: Option<RoomPolicy>,
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub start_time: Option<i64>,
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub end_time: Option<i64>,
388}
389
390impl RoomUpdatePayload {
391    pub fn is_empty(&self) -> bool {
392        self.topic.is_none()
393            && self.agenda.is_none()
394            && self.guidance.is_none()
395            && self.tags.is_none()
396            && self.language.is_none()
397            && self.policy.is_none()
398            && self.start_time.is_none()
399            && self.end_time.is_none()
400    }
401}
402
403/// Payload of `room.member.remove`: removal and optional ban.
404#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
405pub struct RoomMemberRemovePayload {
406    pub member: AgentId,
407    /// Defaults to `false`. `true` additionally bans the agent from the room.
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub ban: Option<bool>,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub reason: Option<String>,
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub references: Option<Vec<String>>,
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub extra: Option<BTreeMap<String, Value>>,
416}
417
418impl RoomMemberRemovePayload {
419    pub fn new(member: AgentId) -> Self {
420        Self {
421            member,
422            ban: None,
423            reason: None,
424            references: None,
425            extra: None,
426        }
427    }
428
429    pub fn banning(&self) -> bool {
430        self.ban.unwrap_or(false)
431    }
432}
433
434/// A room-scoped declaration of a custom event type. The field set is closed.
435#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
436#[serde(deny_unknown_fields)]
437pub struct TypeDef {
438    #[serde(rename = "type")]
439    pub name: String,
440    pub kind: TypeKind,
441    pub title: String,
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub description: Option<String>,
444    /// JSON Schema for the event payload, following the type schema profile.
445    pub schema: Value,
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub roles: Option<Vec<Role>>,
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub instructions: Option<String>,
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub status: Option<TypeStatus>,
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub rate_hint: Option<u32>,
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub max_payload_hint: Option<u64>,
456    /// `None` when absent; an explicit `{}` is kept so a signed declaration
457    /// re-serializes to the bytes that were signed.
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub extra: Option<BTreeMap<String, Value>>,
460}
461
462impl TypeDef {
463    pub fn status(&self) -> TypeStatus {
464        self.status.unwrap_or(TypeStatus::Active)
465    }
466}
467
468/// Per-type adjustments applied when importing a pack.
469#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
470pub struct TypeOverride {
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub roles: Option<Vec<Role>>,
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub instructions: Option<String>,
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub status: Option<TypeStatus>,
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub rate_hint: Option<u32>,
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub max_payload_hint: Option<u64>,
481}
482
483/// Imports a registered pack (`use`) or an external pack (`pack` + `digest`).
484#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
485pub struct PackImport {
486    #[serde(rename = "use", default, skip_serializing_if = "Option::is_none")]
487    pub use_pack: Option<String>,
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub pack: Option<String>,
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub digest: Option<String>,
492    #[serde(default, skip_serializing_if = "Option::is_none")]
493    pub types: Option<Vec<String>>,
494    /// `None` when absent; an explicit `{}` is kept so a signed declaration
495    /// re-serializes to the bytes that were signed.
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub overrides: Option<BTreeMap<String, TypeOverride>>,
498}
499
500/// One entry of `room.create.payload.types` or a `type.define` payload.
501#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
502#[serde(untagged)]
503pub enum TypeDeclaration {
504    Def(TypeDef),
505    Import(PackImport),
506}
507
508#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
509pub struct Pack {
510    pub id: String,
511    pub title: String,
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub description: Option<String>,
514    pub types: Vec<TypeDef>,
515    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
516    pub extra: BTreeMap<String, Value>,
517}
518
519/// The shape of `1.0.packs.json` and externally published pack documents.
520#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
521pub struct PackDocument {
522    pub protocol: String,
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub description: Option<String>,
525    pub packs: Vec<Pack>,
526}
527
528/// Indexes the packs of a document by pack id for registry materialization.
529pub fn pack_map(document: &PackDocument) -> BTreeMap<String, Pack> {
530    document
531        .packs
532        .iter()
533        .map(|pack| (pack.id.clone(), pack.clone()))
534        .collect()
535}
536
537#[derive(Clone, Debug, Serialize, Deserialize)]
538pub struct ServerRecord<P = Value> {
539    pub room_id: String,
540    pub seq: u64,
541    #[serde(default)]
542    pub pre_hash: Option<String>,
543    pub hash: String,
544    pub accepted_at: i64,
545    pub envelope: Envelope<P>,
546}
547
548/// Envelope of a redacted record (Section 14.1): its event ID and type only.
549#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
550#[serde(deny_unknown_fields)]
551pub struct RedactedEnvelope {
552    pub hash: String,
553    pub redacted: bool,
554    #[serde(rename = "type")]
555    pub kind: String,
556}
557
558/// A redacted server record.
559#[derive(Clone, Debug, Serialize, Deserialize)]
560pub struct RedactedServerRecord {
561    pub room_id: String,
562    pub seq: u64,
563    #[serde(default)]
564    pub pre_hash: Option<String>,
565    pub hash: String,
566    pub accepted_at: i64,
567    pub envelope: RedactedEnvelope,
568}
569
570/// A record as it appears in history or an archive: signed or redacted.
571#[derive(Clone, Debug, Serialize, Deserialize)]
572#[serde(untagged)]
573#[allow(clippy::large_enum_variant)]
574pub enum ArchiveRecord {
575    Redacted(RedactedServerRecord),
576    Signed(ServerRecord),
577}
578
579impl ArchiveRecord {
580    pub fn room_id(&self) -> &str {
581        match self {
582            Self::Signed(r) => &r.room_id,
583            Self::Redacted(r) => &r.room_id,
584        }
585    }
586    pub fn seq(&self) -> u64 {
587        match self {
588            Self::Signed(r) => r.seq,
589            Self::Redacted(r) => r.seq,
590        }
591    }
592    pub fn pre_hash(&self) -> Option<&str> {
593        match self {
594            Self::Signed(r) => r.pre_hash.as_deref(),
595            Self::Redacted(r) => r.pre_hash.as_deref(),
596        }
597    }
598    pub fn hash(&self) -> &str {
599        match self {
600            Self::Signed(r) => &r.hash,
601            Self::Redacted(r) => &r.hash,
602        }
603    }
604    pub fn accepted_at(&self) -> i64 {
605        match self {
606            Self::Signed(r) => r.accepted_at,
607            Self::Redacted(r) => r.accepted_at,
608        }
609    }
610    pub fn envelope_hash(&self) -> &str {
611        match self {
612            Self::Signed(r) => &r.envelope.hash,
613            Self::Redacted(r) => &r.envelope.hash,
614        }
615    }
616    /// The event type, kept even when the record is redacted.
617    pub fn event_type(&self) -> &str {
618        match self {
619            Self::Signed(r) => &r.envelope.event.kind,
620            Self::Redacted(r) => &r.envelope.kind,
621        }
622    }
623    pub fn is_redacted(&self) -> bool {
624        matches!(self, Self::Redacted(_))
625    }
626}
627
628impl From<ServerRecord> for ArchiveRecord {
629    fn from(record: ServerRecord) -> Self {
630        Self::Signed(record)
631    }
632}
633
634impl From<RedactedServerRecord> for ArchiveRecord {
635    fn from(record: RedactedServerRecord) -> Self {
636        Self::Redacted(record)
637    }
638}
639
640pub type RoomEventsResponse = ListResponse<ArchiveRecord>;
641
642#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
643pub struct ServerRecordHashPayload {
644    pub room_id: String,
645    pub seq: u64,
646    pub pre_hash: Option<String>,
647    pub envelope_hash: String,
648    pub accepted_at: i64,
649}
650
651#[derive(Clone, Debug, Serialize, Deserialize)]
652pub struct RoomResponse {
653    pub id: String,
654    pub status: RoomState,
655    pub url: String,
656    #[serde(default, skip_serializing_if = "Option::is_none")]
657    pub creator: Option<AgentId>,
658    #[serde(default, skip_serializing_if = "Option::is_none")]
659    pub created_at: Option<i64>,
660    #[serde(default, skip_serializing_if = "Option::is_none")]
661    pub topic: Option<String>,
662    #[serde(default, skip_serializing_if = "Option::is_none")]
663    pub agenda: Option<String>,
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub guidance: Option<String>,
666    #[serde(default, skip_serializing_if = "Option::is_none")]
667    pub visibility: Option<Visibility>,
668    #[serde(default, skip_serializing_if = "Option::is_none")]
669    pub start_time: Option<i64>,
670    #[serde(default, skip_serializing_if = "Option::is_none")]
671    pub end_time: Option<i64>,
672    #[serde(default, skip_serializing_if = "Vec::is_empty")]
673    pub tags: Vec<String>,
674    #[serde(default, skip_serializing_if = "Option::is_none")]
675    pub language: Option<String>,
676    #[serde(default, skip_serializing_if = "Option::is_none")]
677    pub policy: Option<RoomPolicy>,
678    /// Materialized type registry served by the host.
679    #[serde(default, skip_serializing_if = "Vec::is_empty")]
680    pub types: Vec<TypeDef>,
681    pub seq: u64,
682    #[serde(default)]
683    pub pre_hash: Option<String>,
684    pub hash: String,
685    pub accepted_at: i64,
686    /// Latest accepted head-advancing record. Falls back to `seq`/`hash` on older hosts.
687    #[serde(default, skip_serializing_if = "Option::is_none")]
688    pub head: Option<RoomHead>,
689    #[serde(default, skip_serializing_if = "Option::is_none")]
690    pub envelope: Option<Envelope<RoomCreatePayload>>,
691}
692
693#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
694pub struct RoomHead {
695    pub seq: u64,
696    pub hash: String,
697}
698
699/// Payload of a direct `room.join` (Section 9.2).
700#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
701#[serde(deny_unknown_fields)]
702pub struct RoomJoinPayload {
703    pub role: Role,
704    #[serde(default, skip_serializing_if = "Option::is_none")]
705    pub perspective: Option<String>,
706}
707
708/// Payload of a signed `room.join.request` (Section 10).
709#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
710#[serde(deny_unknown_fields)]
711pub struct RoomJoinRequestPayload {
712    pub role: Role,
713    #[serde(default, skip_serializing_if = "Option::is_none")]
714    pub perspective: Option<String>,
715    #[serde(default, skip_serializing_if = "Option::is_none")]
716    pub reason: Option<String>,
717    #[serde(default, skip_serializing_if = "Option::is_none")]
718    pub extra: Option<BTreeMap<String, Value>>,
719}
720
721impl RoomJoinRequestPayload {
722    pub fn new(role: Role) -> Self {
723        Self {
724            role,
725            perspective: None,
726            reason: None,
727            extra: None,
728        }
729    }
730}
731
732/// The join request resource: the signed request plus review state.
733#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
734pub struct RoomJoinRequest {
735    /// Event ID of the signed request.
736    pub id: String,
737    pub request: Envelope<RoomJoinRequestPayload>,
738    pub status: JoinRequestStatus,
739    pub expires_at: i64,
740    #[serde(default, skip_serializing_if = "Option::is_none")]
741    pub reviewed_by: Option<AgentId>,
742    #[serde(default, skip_serializing_if = "Option::is_none")]
743    pub reviewed_at: Option<i64>,
744    #[serde(default, skip_serializing_if = "Option::is_none")]
745    pub review_event_id: Option<String>,
746}
747
748#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
749pub struct AgentStatusInput {
750    pub state: String,
751    #[serde(default, skip_serializing_if = "Option::is_none")]
752    pub summary: Option<String>,
753    #[serde(default, skip_serializing_if = "Option::is_none")]
754    pub seen_seq: Option<u64>,
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub seen_hash: Option<String>,
757    #[serde(default, skip_serializing_if = "Option::is_none")]
758    pub claim_id: Option<String>,
759    #[serde(default, skip_serializing_if = "Option::is_none")]
760    pub activity: Option<String>,
761    /// Optional: when omitted, the host assigns its maximum TTL.
762    #[serde(default, skip_serializing_if = "Option::is_none")]
763    pub expires_at: Option<i64>,
764    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
765    pub extra: BTreeMap<String, Value>,
766}
767
768impl AgentStatusInput {
769    pub fn new(state: impl Into<String>) -> Self {
770        Self {
771            state: state.into(),
772            summary: None,
773            seen_seq: None,
774            seen_hash: None,
775            claim_id: None,
776            activity: None,
777            expires_at: None,
778            extra: BTreeMap::new(),
779        }
780    }
781
782    pub fn with_expires_at(mut self, expires_at: i64) -> Self {
783        self.expires_at = Some(expires_at);
784        self
785    }
786}
787
788#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
789pub struct AgentStatus {
790    pub room_id: String,
791    pub agent_id: AgentId,
792    pub state: String,
793    #[serde(default, skip_serializing_if = "Option::is_none")]
794    pub summary: Option<String>,
795    #[serde(default, skip_serializing_if = "Option::is_none")]
796    pub seen_seq: Option<u64>,
797    #[serde(default, skip_serializing_if = "Option::is_none")]
798    pub seen_hash: Option<String>,
799    #[serde(default, skip_serializing_if = "Option::is_none")]
800    pub claim_id: Option<String>,
801    #[serde(default, skip_serializing_if = "Option::is_none")]
802    pub activity: Option<String>,
803    pub expires_at: i64,
804    pub updated_at: i64,
805    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
806    pub extra: BTreeMap<String, Value>,
807}
808
809pub type AgentStatusListResponse = ListResponse<AgentStatus>;
810
811#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
812#[serde(deny_unknown_fields)]
813pub struct RoomJoinReviewPayload {
814    /// The applicant's signed `room.join.request` envelope.
815    pub request: Envelope<RoomJoinRequestPayload>,
816    pub decision: JoinDecision,
817    /// Required when approving.
818    #[serde(default, skip_serializing_if = "Option::is_none")]
819    pub role: Option<Role>,
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub reason: Option<String>,
822    #[serde(default, skip_serializing_if = "Option::is_none")]
823    pub extra: Option<BTreeMap<String, Value>>,
824}
825
826#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
827pub struct RoleUpdatePayload {
828    pub member: AgentId,
829    pub role: Role,
830    #[serde(default, skip_serializing_if = "Option::is_none")]
831    pub reason: Option<String>,
832    #[serde(default, skip_serializing_if = "Option::is_none")]
833    pub extra: Option<BTreeMap<String, Value>>,
834}
835
836/// Shared payload of `room.leave`, `room.close`, and `room.cancel`.
837#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
838pub struct ReasonPayload {
839    #[serde(default, skip_serializing_if = "Option::is_none")]
840    pub reason: Option<String>,
841    #[serde(default, skip_serializing_if = "Option::is_none")]
842    pub references: Option<Vec<String>>,
843    #[serde(default, skip_serializing_if = "Option::is_none")]
844    pub extra: Option<BTreeMap<String, Value>>,
845}
846
847pub type RoomLeavePayload = ReasonPayload;
848pub type RoomClosePayload = ReasonPayload;
849pub type RoomCancelPayload = ReasonPayload;
850
851#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
852pub struct MessageCreatePayload {
853    pub content_type: String,
854    /// A JSON string, or a JSON object for a JSON media type.
855    pub content: Value,
856    #[serde(default, skip_serializing_if = "Option::is_none")]
857    pub references: Option<Vec<String>>,
858    #[serde(default, skip_serializing_if = "Option::is_none")]
859    pub extra: Option<BTreeMap<String, Value>>,
860}
861
862impl MessageCreatePayload {
863    pub fn new(content_type: impl Into<String>, content: Value) -> Self {
864        Self {
865            content_type: content_type.into(),
866            content,
867            references: None,
868            extra: None,
869        }
870    }
871
872    pub fn text(text: impl Into<String>) -> Self {
873        Self::new("text/plain", Value::String(text.into()))
874    }
875
876    pub fn markdown(markdown: impl Into<String>) -> Self {
877        Self::new("text/markdown", Value::String(markdown.into()))
878    }
879}
880
881/// The `profile` discovery member (Agent Profile Section 8): the Agent
882/// Profile service a host resolves profiles from, and whether local policy
883/// requires a verified profile before accepting an agent's writes.
884#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
885#[serde(deny_unknown_fields)]
886pub struct ProfileResolverMetadata {
887    pub service: String,
888    #[serde(default, skip_serializing_if = "Option::is_none")]
889    pub required: Option<bool>,
890}
891
892#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
893pub struct DiscourseProtocolDiscovery {
894    pub protocol: String,
895    pub service: String,
896    #[serde(default, skip_serializing_if = "Vec::is_empty")]
897    pub features: Vec<String>,
898    #[serde(default, skip_serializing_if = "Vec::is_empty")]
899    pub registered_packs: Vec<String>,
900    #[serde(default, skip_serializing_if = "Option::is_none")]
901    pub profile: Option<ProfileResolverMetadata>,
902    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
903    pub endpoints: BTreeMap<String, String>,
904}
905
906#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
907pub struct ArchiveManifest {
908    pub protocol: String,
909    #[serde(rename = "type")]
910    pub kind: String,
911    pub room_id: String,
912    pub url: String,
913    pub generated_at: i64,
914    pub last_seq: u64,
915    /// Hash of the record at `last_seq`: the archive's commitment to every record.
916    pub last_hash: String,
917    /// Format names mapped to URLs; `jsonl` is the required record log.
918    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
919    pub formats: BTreeMap<String, String>,
920    /// Every external pack the room imported, with a URL serving its exact bytes.
921    #[serde(default, skip_serializing_if = "Vec::is_empty")]
922    pub packs: Vec<ArchivedPack>,
923    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
924    pub extra: BTreeMap<String, Value>,
925}
926
927/// An external pack document retained in an archive (Section 18).
928#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
929#[serde(deny_unknown_fields)]
930pub struct ArchivedPack {
931    pub pack: String,
932    pub digest: String,
933    pub url: String,
934}
935
936pub fn room_create_event(
937    actor: AgentId,
938    created_at: i64,
939    nonce: u64,
940    payload: RoomCreatePayload,
941) -> Event<RoomCreatePayload> {
942    Event::new(
943        PROTOCOL,
944        event_type::ROOM_CREATE,
945        actor,
946        created_at,
947        nonce,
948        payload,
949    )
950}
951
952/// A `room.join.request` carries `room_id` but no base: its author may not be
953/// able to read the room.
954pub fn room_join_request_event(
955    actor: AgentId,
956    created_at: i64,
957    nonce: u64,
958    room_id: impl Into<String>,
959    payload: RoomJoinRequestPayload,
960) -> Event<RoomJoinRequestPayload> {
961    Event::new(
962        PROTOCOL,
963        event_type::ROOM_JOIN_REQUEST,
964        actor,
965        created_at,
966        nonce,
967        payload,
968    )
969    .with_room_id(room_id)
970}
971
972pub fn type_define_event(
973    actor: AgentId,
974    created_at: i64,
975    nonce: u64,
976    room_id: impl Into<String>,
977    base_seq: u64,
978    base_hash: impl Into<String>,
979    declaration: TypeDeclaration,
980) -> Event<TypeDeclaration> {
981    Event::new(
982        PROTOCOL,
983        event_type::TYPE_DEFINE,
984        actor,
985        created_at,
986        nonce,
987        declaration,
988    )
989    .with_room_id(room_id)
990    .with_room_head(base_seq, base_hash)
991}
992
993#[allow(clippy::too_many_arguments)]
994pub fn discourse_event<P>(
995    kind: impl Into<String>,
996    actor: AgentId,
997    created_at: i64,
998    nonce: u64,
999    room_id: impl Into<String>,
1000    base_seq: u64,
1001    base_hash: impl Into<String>,
1002    payload: P,
1003) -> Event<P> {
1004    Event::new(PROTOCOL, kind, actor, created_at, nonce, payload)
1005        .with_room_id(room_id)
1006        .with_room_head(base_seq, base_hash)
1007}
1008
1009pub fn is_builtin_event_type(event_type: &str) -> bool {
1010    BUILTIN_EVENT_TYPES.contains(&event_type)
1011}
1012
1013pub fn event_requires_room_id(event_type: &str) -> bool {
1014    event_type != event_type::ROOM_CREATE
1015}
1016
1017/// Whether events of this type carry `base_seq` / `base_hash`.
1018pub fn event_requires_base(event_type: &str) -> bool {
1019    event_type != event_type::ROOM_CREATE && event_type != event_type::ROOM_JOIN_REQUEST
1020}
1021
1022/// Room IDs are host-assigned and URL-safe (Section 6.1).
1023pub fn validate_room_id(room_id: &str) -> Result<()> {
1024    let valid = (1..=64).contains(&room_id.len())
1025        && room_id
1026            .bytes()
1027            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
1028    if valid {
1029        Ok(())
1030    } else {
1031        Err(invalid_event("room_id must match [A-Za-z0-9_-]{1,64}"))
1032    }
1033}
1034
1035fn invalid_event(message: impl Into<String>) -> SdkError {
1036    SdkError::protocol("invalid_event", message)
1037}
1038
1039pub fn validate_discourse_envelope<P>(envelope: &Envelope<P>) -> Result<()>
1040where
1041    P: Serialize,
1042{
1043    verify_envelope(envelope)?;
1044    validate_discourse_event_fields(&envelope.event)
1045}
1046
1047/// Section 5 event-shape rules: closed fields, room ID, base, and mentions.
1048pub fn validate_discourse_event_fields<P>(event: &Event<P>) -> Result<()> {
1049    if event.protocol != PROTOCOL {
1050        return Err(SdkError::InvalidEventProtocol {
1051            expected: PROTOCOL.to_owned(),
1052            actual: event.protocol.clone(),
1053        });
1054    }
1055    match event.kind.as_str() {
1056        event_type::ROOM_CREATE => validate_event_fields(event, &[]),
1057        event_type::ROOM_JOIN_REQUEST => {
1058            validate_event_fields(event, &["room_id"])?;
1059            validate_room_id(event.room_id.as_deref().ok_or(SdkError::MissingRoomId)?)
1060        }
1061        _ => {
1062            validate_event_fields(event, &["room_id", "base_seq", "base_hash", "mentions"])?;
1063            validate_room_id(event.room_id.as_deref().ok_or(SdkError::MissingRoomId)?)?;
1064            validate_room_head_precondition(event)?;
1065            validate_mentions(event.mentions())
1066        }
1067    }
1068}
1069
1070pub fn validate_room_path<P>(envelope: &Envelope<P>, path_room_id: &str) -> Result<()> {
1071    validate_discourse_event_fields(&envelope.event)?;
1072    if envelope.event.kind == event_type::ROOM_CREATE {
1073        return Ok(());
1074    }
1075    match envelope.event.room_id.as_deref() {
1076        Some(actual) if actual == path_room_id => Ok(()),
1077        Some(actual) => Err(SdkError::RoomIdMismatch {
1078            expected: path_room_id.to_owned(),
1079            actual: actual.to_owned(),
1080        }),
1081        None => Err(SdkError::MissingRoomId),
1082    }
1083}
1084
1085fn validate_room_head_precondition<P>(event: &Event<P>) -> Result<()> {
1086    match (event.base_seq, event.base_hash.as_deref()) {
1087        (Some(seq), Some(hash)) if seq > 0 && seq <= MAX_SAFE_NONCE && !hash.trim().is_empty() => {
1088            Ok(())
1089        }
1090        (Some(0), _) => Err(invalid_event(
1091            "base_seq must be a positive safe JSON integer",
1092        )),
1093        (Some(_), Some(hash)) if hash.trim().is_empty() => {
1094            Err(invalid_event("base_hash must not be empty"))
1095        }
1096        (Some(seq), _) if seq > MAX_SAFE_NONCE => {
1097            Err(invalid_event("base_seq must be a safe JSON integer"))
1098        }
1099        _ => Err(invalid_event("room event requires base_seq and base_hash")),
1100    }
1101}
1102
1103fn validate_mentions(mentions: &[AgentId]) -> Result<()> {
1104    if mentions.len() > MAX_MENTIONS {
1105        return Err(invalid_event(format!(
1106            "mentions must not exceed {MAX_MENTIONS} entries"
1107        )));
1108    }
1109    let unique: BTreeSet<&AgentId> = mentions.iter().collect();
1110    if unique.len() != mentions.len() {
1111        return Err(invalid_event("mentions must be unique"));
1112    }
1113    Ok(())
1114}
1115
1116/// Checks the shape of a custom event type name: lowercase dot-separated,
1117/// at least two segments, not built-in, not under a reserved prefix.
1118pub fn validate_custom_event_type_name(name: &str) -> Result<()> {
1119    let segments: Vec<&str> = name.split('.').collect();
1120    let valid_shape = segments.len() >= 2 && segments.iter().all(|segment| {
1121        let mut chars = segment.chars();
1122        matches!(chars.next(), Some(first) if first.is_ascii_lowercase() || first.is_ascii_digit())
1123            && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
1124    });
1125    if !valid_shape {
1126        return Err(invalid_event(format!("invalid event type name: {name}")));
1127    }
1128    if is_builtin_event_type(name) {
1129        return Err(invalid_event(format!("{name} is a built-in event type")));
1130    }
1131    if RESERVED_TYPE_PREFIXES
1132        .iter()
1133        .any(|prefix| name.starts_with(prefix))
1134    {
1135        return Err(invalid_event(format!("{name} uses a reserved type prefix")));
1136    }
1137    Ok(())
1138}
1139
1140pub fn validate_type_def(def: &TypeDef) -> Result<()> {
1141    validate_custom_event_type_name(&def.name)?;
1142    if def.title.trim().is_empty() {
1143        return Err(invalid_event("type definition title must not be empty"));
1144    }
1145    if !def.schema.is_object() {
1146        return Err(SdkError::protocol(
1147            "invalid_type_schema",
1148            "type definition schema must be a JSON Schema object",
1149        ));
1150    }
1151    validate_type_schema_profile(&def.schema)?;
1152    compile_schema(&def.schema)?;
1153    if matches!(&def.roles, Some(roles) if roles.is_empty()) {
1154        return Err(invalid_event("type definition roles must not be empty"));
1155    }
1156    if matches!(def.rate_hint, Some(0)) || matches!(def.max_payload_hint, Some(0)) {
1157        return Err(invalid_event("type definition hints must be positive"));
1158    }
1159    Ok(())
1160}
1161
1162/// `<algorithm>:<base64url-digest>` content digests (Section 12.5).
1163pub fn validate_content_digest(digest: &str) -> Result<()> {
1164    let valid = digest.split_once(':').is_some_and(|(algorithm, value)| {
1165        matches!(algorithm, "sha256" | "sha3-256")
1166            && value.len() == 43
1167            && value
1168                .bytes()
1169                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
1170    });
1171    if valid {
1172        Ok(())
1173    } else {
1174        Err(invalid_event(
1175            "external pack digest must be <sha256|sha3-256>:<base64url-digest>",
1176        ))
1177    }
1178}
1179
1180pub fn validate_pack_import(import: &PackImport) -> Result<()> {
1181    match (&import.use_pack, &import.pack, &import.digest) {
1182        (Some(id), None, None) => {
1183            if !is_registered_pack_id(id) {
1184                return Err(invalid_event(format!("invalid registered pack id: {id}")));
1185            }
1186        }
1187        (None, Some(pack), Some(digest)) => {
1188            if !pack.starts_with("https://") {
1189                return Err(invalid_event("external pack must be an HTTPS URL"));
1190            }
1191            validate_content_digest(digest)?;
1192        }
1193        _ => {
1194            return Err(invalid_event(
1195                "pack import requires either use, or pack with digest",
1196            ));
1197        }
1198    }
1199    if let Some(types) = &import.types {
1200        if types.is_empty() {
1201            return Err(invalid_event("pack import types subset must not be empty"));
1202        }
1203        if types.iter().collect::<BTreeSet<_>>().len() != types.len() {
1204            return Err(SdkError::protocol(
1205                "type_conflict",
1206                "pack import types subset has duplicates",
1207            ));
1208        }
1209    }
1210    Ok(())
1211}
1212
1213pub fn validate_type_declaration(declaration: &TypeDeclaration) -> Result<()> {
1214    match declaration {
1215        TypeDeclaration::Def(def) => validate_type_def(def),
1216        TypeDeclaration::Import(import) => validate_pack_import(import),
1217    }
1218}
1219
1220fn is_registered_pack_id(id: &str) -> bool {
1221    let Some(rest) = id.strip_prefix("adp:") else {
1222        return false;
1223    };
1224    let Some((name, version)) = rest.split_once('/') else {
1225        return false;
1226    };
1227    !name.is_empty()
1228        && name
1229            .chars()
1230            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1231        && version.split('.').count() == 2
1232        && version
1233            .split('.')
1234            .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
1235}
1236
1237// ── Type schema profile (Section 12.3.1).
1238
1239const FORBIDDEN_SCHEMA_KEYWORDS: [&str; 5] = [
1240    "$dynamicRef",
1241    "$dynamicAnchor",
1242    "$recursiveRef",
1243    "$recursiveAnchor",
1244    "$vocabulary",
1245];
1246const SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
1247const SUBSCHEMA_KEYWORDS: [&str; 11] = [
1248    "items",
1249    "additionalProperties",
1250    "not",
1251    "if",
1252    "then",
1253    "else",
1254    "contains",
1255    "propertyNames",
1256    "unevaluatedItems",
1257    "unevaluatedProperties",
1258    "additionalItems",
1259];
1260const SUBSCHEMA_ARRAY_KEYWORDS: [&str; 4] = ["allOf", "anyOf", "oneOf", "prefixItems"];
1261const SUBSCHEMA_MAP_KEYWORDS: [&str; 5] = [
1262    "properties",
1263    "patternProperties",
1264    "$defs",
1265    "definitions",
1266    "dependentSchemas",
1267];
1268
1269fn walk_schema(
1270    schema: &Value,
1271    visit: &mut dyn FnMut(&serde_json::Map<String, Value>) -> Result<()>,
1272) -> Result<()> {
1273    let Some(node) = schema.as_object() else {
1274        return Ok(());
1275    };
1276    visit(node)?;
1277    for key in SUBSCHEMA_KEYWORDS {
1278        if let Some(child) = node.get(key) {
1279            walk_schema(child, visit)?;
1280        }
1281    }
1282    for key in SUBSCHEMA_ARRAY_KEYWORDS {
1283        if let Some(Value::Array(items)) = node.get(key) {
1284            for item in items {
1285                walk_schema(item, visit)?;
1286            }
1287        }
1288    }
1289    for key in SUBSCHEMA_MAP_KEYWORDS {
1290        if let Some(Value::Object(map)) = node.get(key) {
1291            for value in map.values() {
1292                walk_schema(value, visit)?;
1293            }
1294        }
1295    }
1296    Ok(())
1297}
1298
1299fn invalid_schema(message: impl Into<String>) -> SdkError {
1300    SdkError::protocol("invalid_type_schema", message)
1301}
1302
1303/// Enforces the type schema profile (Section 12.3.1): fragment-only `$ref`,
1304/// no dynamic or recursive references, the 2020-12 dialect, and portable
1305/// regular expressions.
1306pub fn validate_type_schema_profile(schema: &Value) -> Result<()> {
1307    walk_schema(schema, &mut |node| {
1308        for keyword in FORBIDDEN_SCHEMA_KEYWORDS {
1309            if node.contains_key(keyword) {
1310                return Err(invalid_schema(format!("{keyword} is not allowed")));
1311            }
1312        }
1313        if let Some(dialect) = node.get("$schema") {
1314            if dialect.as_str() != Some(SCHEMA_DIALECT) {
1315                return Err(invalid_schema("$schema must be the draft 2020-12 dialect"));
1316            }
1317        }
1318        if let Some(reference) = node.get("$ref") {
1319            if !reference.as_str().is_some_and(|r| r.starts_with('#')) {
1320                return Err(invalid_schema("$ref must be a fragment inside the schema"));
1321            }
1322        }
1323        if let Some(pattern) = node.get("pattern") {
1324            let pattern = pattern
1325                .as_str()
1326                .ok_or_else(|| invalid_schema("pattern must be a string"))?;
1327            validate_portable_pattern(pattern)?;
1328        }
1329        if let Some(Value::Object(properties)) = node.get("patternProperties") {
1330            for pattern in properties.keys() {
1331                validate_portable_pattern(pattern)?;
1332            }
1333        }
1334        Ok(())
1335    })
1336}
1337
1338/// Checks that a pattern is an I-Regexp (RFC 9485) without `\p{…}`/`\P{…}`
1339/// and without `.` outside a character class, optionally anchored with a
1340/// leading `^` and a trailing `$` (Section 12.3.1).
1341pub fn validate_portable_pattern(pattern: &str) -> Result<()> {
1342    let reject =
1343        |reason: &str| invalid_schema(format!("pattern {pattern:?} is not portable: {reason}"));
1344    let mut body = pattern.strip_prefix('^').unwrap_or(pattern);
1345    if body.ends_with('$') && !body.ends_with("\\$") {
1346        body = &body[..body.len() - 1];
1347    }
1348    let chars: Vec<char> = body.chars().collect();
1349    const SINGLE_ESCAPES: &str = "()*+-.?[\\]^{|}nrt";
1350    let mut i = 0;
1351    let mut depth = 0usize;
1352    let mut can_quantify = false;
1353    let read_escape = |i: &mut usize| -> Result<()> {
1354        match chars.get(*i + 1) {
1355            None => Err(reject("dangling escape")),
1356            Some(next) if !SINGLE_ESCAPES.contains(*next) => {
1357                Err(reject(&format!("escape \\{next}")))
1358            }
1359            Some(_) => {
1360                *i += 2;
1361                Ok(())
1362            }
1363        }
1364    };
1365    while i < chars.len() {
1366        match chars[i] {
1367            '\\' => {
1368                read_escape(&mut i)?;
1369                can_quantify = true;
1370            }
1371            '[' => {
1372                i += 1;
1373                if chars.get(i) == Some(&'^') {
1374                    i += 1;
1375                }
1376                // A leading `]` is literal in some engines and an empty class in others.
1377                if chars.get(i) == Some(&']') {
1378                    return Err(reject("empty character class"));
1379                }
1380                while i < chars.len() && chars[i] != ']' {
1381                    match chars[i] {
1382                        '\\' => read_escape(&mut i)?,
1383                        '[' => return Err(reject("nested character class")),
1384                        _ => i += 1,
1385                    }
1386                }
1387                if chars.get(i) != Some(&']') {
1388                    return Err(reject("unterminated character class"));
1389                }
1390                i += 1;
1391                can_quantify = true;
1392            }
1393            '(' => {
1394                if chars.get(i + 1) == Some(&'?') {
1395                    return Err(reject("group modifiers"));
1396                }
1397                depth += 1;
1398                i += 1;
1399                can_quantify = false;
1400            }
1401            ')' => {
1402                if depth == 0 {
1403                    return Err(reject("unbalanced parenthesis"));
1404                }
1405                depth -= 1;
1406                i += 1;
1407                can_quantify = true;
1408            }
1409            '|' => {
1410                i += 1;
1411                can_quantify = false;
1412            }
1413            '*' | '+' | '?' => {
1414                if !can_quantify {
1415                    return Err(reject("quantifier without operand"));
1416                }
1417                i += 1;
1418                if matches!(chars.get(i), Some('?' | '+')) {
1419                    return Err(reject("lazy or possessive quantifier"));
1420                }
1421                can_quantify = false;
1422            }
1423            '{' => {
1424                if !can_quantify {
1425                    return Err(reject("quantifier without operand"));
1426                }
1427                let mut j = i + 1;
1428                let start = j;
1429                while j < chars.len() && chars[j].is_ascii_digit() {
1430                    j += 1;
1431                }
1432                if j == start {
1433                    return Err(reject("malformed quantifier"));
1434                }
1435                if chars.get(j) == Some(&',') {
1436                    j += 1;
1437                    while j < chars.len() && chars[j].is_ascii_digit() {
1438                        j += 1;
1439                    }
1440                }
1441                if chars.get(j) != Some(&'}') {
1442                    return Err(reject("malformed quantifier"));
1443                }
1444                i = j + 1;
1445                if matches!(chars.get(i), Some('?' | '+')) {
1446                    return Err(reject("lazy or possessive quantifier"));
1447                }
1448                can_quantify = false;
1449            }
1450            '.' => return Err(reject("'.' outside a character class")),
1451            '^' | '$' => return Err(reject("anchor inside the pattern")),
1452            '}' | ']' => return Err(reject(&format!("unescaped {}", chars[i]))),
1453            _ => {
1454                i += 1;
1455                can_quantify = true;
1456            }
1457        }
1458    }
1459    if depth != 0 {
1460        return Err(reject("unbalanced parenthesis"));
1461    }
1462    Ok(())
1463}
1464
1465const ROLE_ORDER: [Role; 3] = [Role::Moderator, Role::Speaker, Role::Observer];
1466
1467/// Section 8.3 rules for a room policy.
1468pub fn validate_room_policy(policy: Option<&RoomPolicy>) -> Result<()> {
1469    let Some(policy) = policy else {
1470        return Ok(());
1471    };
1472    if matches!(policy.max_speakers, Some(0)) {
1473        return Err(invalid_event("max_speakers must be a positive integer"));
1474    }
1475    let observer_allowed = policy.observer_allowed.unwrap_or(true);
1476    for role in policy.invites.iter().flat_map(|invites| invites.values()) {
1477        if *role == Role::Observer && !observer_allowed {
1478            return Err(SdkError::protocol(
1479                "role_not_allowed",
1480                "observers are not allowed",
1481            ));
1482        }
1483    }
1484    if let Some(open_roles) = &policy.open_roles {
1485        if open_roles.iter().collect::<BTreeSet<_>>().len() != open_roles.len() {
1486            return Err(invalid_event("open_roles must be a list of unique roles"));
1487        }
1488        for role in open_roles {
1489            match role {
1490                Role::Moderator => {
1491                    return Err(invalid_event("open_roles cannot contain moderator"))
1492                }
1493                Role::Observer if !observer_allowed => {
1494                    return Err(SdkError::protocol(
1495                        "role_not_allowed",
1496                        "observers are not allowed",
1497                    ))
1498                }
1499                _ => {}
1500            }
1501        }
1502    }
1503    Ok(())
1504}
1505
1506/// Section 8.3 rule tying the policy to the room's visibility: a private
1507/// room admits agents only by invitation or review, so its `open_roles`
1508/// must be empty.
1509pub fn validate_room_visibility_policy(
1510    visibility: Visibility,
1511    policy: Option<&RoomPolicy>,
1512) -> Result<()> {
1513    let open = policy
1514        .and_then(|p| p.open_roles.as_ref())
1515        .is_some_and(|roles| !roles.is_empty());
1516    if visibility == Visibility::Private && open {
1517        return Err(invalid_event("a private room cannot have open roles"));
1518    }
1519    Ok(())
1520}
1521
1522/// The roles any agent may take by direct `room.join` (Section 8.3): the
1523/// explicit `open_roles`, or by default `observer` in a public room when
1524/// observers are allowed, and none otherwise.
1525pub fn effective_open_roles(visibility: Visibility, policy: Option<&RoomPolicy>) -> Vec<Role> {
1526    if let Some(open_roles) = policy.and_then(|p| p.open_roles.clone()) {
1527        return open_roles;
1528    }
1529    if visibility == Visibility::Public && policy.and_then(|p| p.observer_allowed) != Some(false) {
1530        vec![Role::Observer]
1531    } else {
1532        Vec::new()
1533    }
1534}
1535
1536/// Section 9.2 direct-join eligibility: the actor is invited with exactly
1537/// `role`, or `role` is one of the room's effective open roles. Bans and
1538/// quotas are separate host checks.
1539pub fn can_join_directly(
1540    visibility: Visibility,
1541    policy: Option<&RoomPolicy>,
1542    actor: &AgentId,
1543    role: Role,
1544) -> bool {
1545    if policy
1546        .and_then(|p| p.invites.as_ref())
1547        .and_then(|invites| invites.get(actor))
1548        == Some(&role)
1549    {
1550        return true;
1551    }
1552    effective_open_roles(visibility, policy).contains(&role)
1553}
1554
1555pub fn validate_room_create_payload(payload: &RoomCreatePayload) -> Result<()> {
1556    if validate_origin(&payload.host).is_err() {
1557        return Err(invalid_event("room.create host must be an HTTPS origin"));
1558    }
1559    if payload.topic.trim().is_empty() {
1560        return Err(invalid_event("room topic must not be empty"));
1561    }
1562    if payload.start_time >= payload.end_time {
1563        return Err(invalid_event("start_time must be before end_time"));
1564    }
1565    validate_room_policy(payload.policy.as_ref())?;
1566    validate_room_visibility_policy(payload.visibility, payload.policy.as_ref())?;
1567    for declaration in payload.types.iter().flatten() {
1568        validate_type_declaration(declaration)?;
1569    }
1570    Ok(())
1571}
1572
1573/// Host binding check (Section 8.1): `host` must be the receiving host's API origin.
1574pub fn validate_room_create_host(payload: &RoomCreatePayload, host_origin: &str) -> Result<()> {
1575    if payload.host == host_origin {
1576        Ok(())
1577    } else {
1578        Err(SdkError::protocol(
1579            "host_mismatch",
1580            format!("room.create names {}, not {host_origin}", payload.host),
1581        ))
1582    }
1583}
1584
1585pub fn validate_message_create_payload(payload: &MessageCreatePayload) -> Result<()> {
1586    if payload.content_type.trim().is_empty() {
1587        return Err(invalid_event("content_type must not be empty"));
1588    }
1589    if !payload.content.is_string() && !payload.content.is_object() {
1590        return Err(invalid_event("content must be a string or an object"));
1591    }
1592    Ok(())
1593}
1594
1595/// Verifies a signed `room.join.request` envelope for embedding or review:
1596/// hash, signature, and shape — historical verification, without the live
1597/// time window or nonce check.
1598pub fn validate_join_request_envelope(
1599    envelope: &Envelope<RoomJoinRequestPayload>,
1600    room_id: Option<&str>,
1601) -> Result<()> {
1602    validate_discourse_envelope(envelope)?;
1603    if envelope.event.kind != event_type::ROOM_JOIN_REQUEST {
1604        return Err(invalid_event(
1605            "embedded request must be a room.join.request",
1606        ));
1607    }
1608    if let Some(room_id) = room_id {
1609        if envelope.event.room_id.as_deref() != Some(room_id) {
1610            return Err(SdkError::RoomIdMismatch {
1611                expected: room_id.to_owned(),
1612                actual: envelope.event.room_id.clone().unwrap_or_default(),
1613            });
1614        }
1615    }
1616    Ok(())
1617}
1618
1619/// Shape checks for `room.join.review`, including the embedded signed request.
1620pub fn validate_room_join_review_payload(
1621    payload: &RoomJoinReviewPayload,
1622    room_id: Option<&str>,
1623) -> Result<()> {
1624    validate_join_request_envelope(&payload.request, room_id)?;
1625    if payload.decision == JoinDecision::Approve && payload.role.is_none() {
1626        return Err(invalid_event("an approving review requires a role"));
1627    }
1628    Ok(())
1629}
1630
1631/// Shape checks for a `room.update` payload. State-dependent rules — room
1632/// status, effective time ordering against the current contract — remain
1633/// host-side.
1634pub fn validate_room_update_payload(payload: &RoomUpdatePayload) -> Result<()> {
1635    if payload.is_empty() {
1636        return Err(invalid_event("room.update payload must not be empty"));
1637    }
1638    if matches!(&payload.topic, Some(topic) if topic.trim().is_empty()) {
1639        return Err(invalid_event("room topic must not be empty"));
1640    }
1641    if let (Some(start_time), Some(end_time)) = (payload.start_time, payload.end_time) {
1642        if start_time >= end_time {
1643            return Err(invalid_event("start_time must be before end_time"));
1644        }
1645    }
1646    validate_room_policy(payload.policy.as_ref())
1647}
1648
1649/// Shape checks for a `room.member.remove` payload. Creator, self, and
1650/// membership checks remain host-side.
1651pub fn validate_room_member_remove_payload(payload: &RoomMemberRemovePayload) -> Result<()> {
1652    payload.member.public_key_bytes()?;
1653    Ok(())
1654}
1655
1656/// The effective set of type definitions active in a room.
1657#[derive(Clone, Debug, Default)]
1658pub struct TypeRegistry {
1659    types: BTreeMap<String, TypeDef>,
1660}
1661
1662impl TypeRegistry {
1663    pub fn new() -> Self {
1664        Self::default()
1665    }
1666
1667    /// Materializes a registry from the `room.create` declarations, resolving
1668    /// pack imports from `packs`, keyed by registered pack id or external pack
1669    /// URI. A type name may appear only once across these declarations.
1670    pub fn from_declarations(
1671        declarations: &[TypeDeclaration],
1672        packs: &BTreeMap<String, Pack>,
1673    ) -> Result<Self> {
1674        let mut registry = Self::new();
1675        let mut declared = BTreeSet::new();
1676        for declaration in declarations {
1677            for name in registry.apply(declaration, packs)? {
1678                if !declared.insert(name.clone()) {
1679                    return Err(SdkError::protocol(
1680                        "type_conflict",
1681                        format!("type {name} is declared twice"),
1682                    ));
1683                }
1684            }
1685        }
1686        Ok(registry)
1687    }
1688
1689    /// Applies one declaration — an inline definition or a pack import — and
1690    /// returns the type names it declared. Declaring an existing type is a
1691    /// redefinition: it must keep the type's kind, and the latest definition wins.
1692    pub fn apply(
1693        &mut self,
1694        declaration: &TypeDeclaration,
1695        packs: &BTreeMap<String, Pack>,
1696    ) -> Result<Vec<String>> {
1697        match declaration {
1698            TypeDeclaration::Def(def) => {
1699                self.define(def.clone())?;
1700                Ok(vec![def.name.clone()])
1701            }
1702            TypeDeclaration::Import(import) => self.import(import, packs),
1703        }
1704    }
1705
1706    pub fn define(&mut self, def: TypeDef) -> Result<()> {
1707        validate_type_def(&def)?;
1708        if let Some(existing) = self.types.get(&def.name) {
1709            if existing.kind != def.kind {
1710                return Err(SdkError::protocol(
1711                    "type_conflict",
1712                    format!("type {} cannot change kind on redefinition", def.name),
1713                ));
1714            }
1715        }
1716        self.types.insert(def.name.clone(), def);
1717        Ok(())
1718    }
1719
1720    fn import(
1721        &mut self,
1722        import: &PackImport,
1723        packs: &BTreeMap<String, Pack>,
1724    ) -> Result<Vec<String>> {
1725        validate_pack_import(import)?;
1726        let reference = import
1727            .use_pack
1728            .as_deref()
1729            .or(import.pack.as_deref())
1730            .expect("validated pack import has a reference");
1731        let pack = packs
1732            .get(reference)
1733            .ok_or_else(|| SdkError::PackUnavailable(reference.to_owned()))?;
1734        let mut available = BTreeSet::new();
1735        for def in &pack.types {
1736            if !available.insert(def.name.as_str()) {
1737                return Err(SdkError::protocol(
1738                    "type_conflict",
1739                    format!("pack {reference} defines {} twice", def.name),
1740                ));
1741            }
1742        }
1743        if let Some(subset) = &import.types {
1744            for name in subset {
1745                if !available.contains(name.as_str()) {
1746                    return Err(SdkError::protocol(
1747                        "type_conflict",
1748                        format!("type {name} is not in pack {reference}"),
1749                    ));
1750                }
1751            }
1752        }
1753        for name in import.overrides.iter().flat_map(BTreeMap::keys) {
1754            let imported = import
1755                .types
1756                .as_ref()
1757                .map(|subset| subset.contains(name))
1758                .unwrap_or_else(|| available.contains(name.as_str()));
1759            if !imported {
1760                return Err(SdkError::protocol(
1761                    "type_conflict",
1762                    format!("override target {name} is not imported from pack {reference}"),
1763                ));
1764            }
1765        }
1766        let mut declared = Vec::new();
1767        for def in &pack.types {
1768            if let Some(subset) = &import.types {
1769                if !subset.contains(&def.name) {
1770                    continue;
1771                }
1772            }
1773            let mut def = def.clone();
1774            if let Some(over) = import
1775                .overrides
1776                .as_ref()
1777                .and_then(|overrides| overrides.get(&def.name))
1778            {
1779                if let Some(roles) = &over.roles {
1780                    def.roles = Some(roles.clone());
1781                }
1782                if let Some(instructions) = &over.instructions {
1783                    def.instructions = Some(instructions.clone());
1784                }
1785                if let Some(status) = over.status {
1786                    def.status = Some(status);
1787                }
1788                if let Some(rate_hint) = over.rate_hint {
1789                    def.rate_hint = Some(rate_hint);
1790                }
1791                if let Some(max_payload_hint) = over.max_payload_hint {
1792                    def.max_payload_hint = Some(max_payload_hint);
1793                }
1794            }
1795            declared.push(def.name.clone());
1796            self.define(def)?;
1797        }
1798        Ok(declared)
1799    }
1800
1801    pub fn get(&self, event_type: &str) -> Option<&TypeDef> {
1802        self.types.get(event_type)
1803    }
1804
1805    pub fn contains(&self, event_type: &str) -> bool {
1806        self.types.contains_key(event_type)
1807    }
1808
1809    pub fn len(&self) -> usize {
1810        self.types.len()
1811    }
1812
1813    pub fn is_empty(&self) -> bool {
1814        self.types.is_empty()
1815    }
1816
1817    pub fn definitions(&self) -> impl Iterator<Item = &TypeDef> {
1818        self.types.values()
1819    }
1820
1821    /// Validates a custom event payload against the type's schema and status.
1822    pub fn validate_payload(&self, event_type: &str, payload: &Value) -> Result<()> {
1823        let def = self
1824            .get(event_type)
1825            .ok_or_else(|| SdkError::TypeNotDefined(event_type.to_owned()))?;
1826        if def.status() == TypeStatus::Disabled {
1827            return Err(SdkError::TypeDisabled(event_type.to_owned()));
1828        }
1829        let validator = compile_schema(&def.schema)?;
1830        if let Err(error) = validator.validate(payload) {
1831            return Err(SdkError::PayloadSchemaViolation(format!(
1832                "{event_type}: {error}"
1833            )));
1834        }
1835        Ok(())
1836    }
1837}
1838
1839/// Validates an event payload: built-in payloads are accepted as-is (use the
1840/// typed validators for them); custom payloads must satisfy the registry.
1841pub fn validate_event_against_registry(
1842    event_type: &str,
1843    payload: &Value,
1844    registry: &TypeRegistry,
1845) -> Result<()> {
1846    if is_builtin_event_type(event_type) {
1847        return Ok(());
1848    }
1849    registry.validate_payload(event_type, payload)
1850}
1851
1852fn compile_schema(schema: &Value) -> Result<jsonschema::Validator> {
1853    // Annotation keywords such as `format` are never asserted (Section 12.3.1).
1854    jsonschema::options()
1855        .with_draft(jsonschema::Draft::Draft202012)
1856        .should_validate_formats(false)
1857        .build(schema)
1858        .map_err(|err| invalid_schema(format!("invalid type schema: {err}")))
1859}
1860
1861/// Verifies a `<algorithm>:<base64url-digest>` content digest over raw bytes.
1862/// Supports `sha256` and `sha3-256`.
1863pub fn verify_pack_digest(bytes: &[u8], digest: &str) -> Result<()> {
1864    validate_content_digest(digest)
1865        .map_err(|_| SdkError::PackUnavailable(format!("invalid digest format: {digest}")))?;
1866    let (algorithm, expected) = digest.split_once(':').expect("validated digest");
1867    let actual = match algorithm {
1868        "sha256" => URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)),
1869        _ => URL_SAFE_NO_PAD.encode(Sha3_256::digest(bytes)),
1870    };
1871    if actual == expected {
1872        Ok(())
1873    } else {
1874        Err(SdkError::PackUnavailable("pack digest mismatch".to_owned()))
1875    }
1876}
1877
1878pub fn server_record_hash_payload(
1879    room_id: &str,
1880    seq: u64,
1881    pre_hash: Option<&str>,
1882    envelope_hash: &str,
1883    accepted_at: i64,
1884) -> ServerRecordHashPayload {
1885    ServerRecordHashPayload {
1886        room_id: room_id.to_owned(),
1887        seq,
1888        pre_hash: pre_hash.map(str::to_owned),
1889        envelope_hash: envelope_hash.to_owned(),
1890        accepted_at,
1891    }
1892}
1893
1894pub fn server_record_hash(
1895    room_id: &str,
1896    seq: u64,
1897    pre_hash: Option<&str>,
1898    envelope_hash: &str,
1899    accepted_at: i64,
1900) -> Result<String> {
1901    hash_canonical_json(&server_record_hash_payload(
1902        room_id,
1903        seq,
1904        pre_hash,
1905        envelope_hash,
1906        accepted_at,
1907    ))
1908}
1909
1910pub fn build_server_record<P>(
1911    room_id: impl Into<String>,
1912    seq: u64,
1913    pre_hash: Option<String>,
1914    accepted_at: i64,
1915    envelope: Envelope<P>,
1916) -> Result<ServerRecord<P>> {
1917    let room_id = room_id.into();
1918    let hash = server_record_hash(
1919        &room_id,
1920        seq,
1921        pre_hash.as_deref(),
1922        &envelope.hash,
1923        accepted_at,
1924    )
1925    .expect("server record hash payload is always serializable");
1926    Ok(ServerRecord {
1927        room_id,
1928        seq,
1929        pre_hash,
1930        hash,
1931        accepted_at,
1932        envelope,
1933    })
1934}
1935
1936/// Replaces a record's envelope with its redacted form (Section 14.1). Only
1937/// `message.create` and custom-type records may be redacted.
1938pub fn redact_server_record(record: &ServerRecord) -> Result<RedactedServerRecord> {
1939    let kind = record.envelope.event.kind.clone();
1940    if is_builtin_event_type(&kind) && kind != event_type::MESSAGE_CREATE {
1941        return Err(invalid_event(format!("{kind} records cannot be redacted")));
1942    }
1943    Ok(RedactedServerRecord {
1944        room_id: record.room_id.clone(),
1945        seq: record.seq,
1946        pre_hash: record.pre_hash.clone(),
1947        hash: record.hash.clone(),
1948        accepted_at: record.accepted_at,
1949        envelope: RedactedEnvelope {
1950            hash: record.envelope.hash.clone(),
1951            redacted: true,
1952            kind,
1953        },
1954    })
1955}
1956
1957pub fn verify_server_record<P>(record: &ServerRecord<P>) -> Result<()>
1958where
1959    P: Serialize,
1960{
1961    verify_record_hash(
1962        &record.room_id,
1963        record.seq,
1964        record.pre_hash.as_deref(),
1965        &record.envelope.hash,
1966        record.accepted_at,
1967        &record.hash,
1968    )
1969}
1970
1971fn verify_record_hash(
1972    room_id: &str,
1973    seq: u64,
1974    pre_hash: Option<&str>,
1975    envelope_hash: &str,
1976    accepted_at: i64,
1977    hash: &str,
1978) -> Result<()> {
1979    let expected = server_record_hash(room_id, seq, pre_hash, envelope_hash, accepted_at)
1980        .expect("server record hash payload is always serializable");
1981    if hash == expected {
1982        Ok(())
1983    } else {
1984        Err(SdkError::InvalidEventHash {
1985            expected,
1986            actual: hash.to_owned(),
1987        })
1988    }
1989}
1990
1991/// Verifies one signed or redacted record's hash; a redacted record must be
1992/// of a redactable type.
1993pub fn verify_archive_record(record: &ArchiveRecord) -> Result<()> {
1994    verify_record_hash(
1995        record.room_id(),
1996        record.seq(),
1997        record.pre_hash(),
1998        record.envelope_hash(),
1999        record.accepted_at(),
2000        record.hash(),
2001    )?;
2002    if let ArchiveRecord::Redacted(redacted) = record {
2003        let kind = redacted.envelope.kind.as_str();
2004        if !redacted.envelope.redacted
2005            || (is_builtin_event_type(kind) && kind != event_type::MESSAGE_CREATE)
2006        {
2007            return Err(SdkError::InvalidPayload(format!(
2008                "{kind} records cannot be redacted"
2009            )));
2010        }
2011    }
2012    Ok(())
2013}
2014
2015pub fn verify_server_record_chain<P>(records: &[ServerRecord<P>]) -> Result<()>
2016where
2017    P: Serialize,
2018{
2019    let mut previous: Option<&ServerRecord<P>> = None;
2020    for record in records {
2021        verify_server_record(record)?;
2022        check_link(
2023            record.seq,
2024            record.pre_hash.as_deref(),
2025            previous.map(|p| (p.seq, p.hash.as_str())),
2026        )?;
2027        previous = Some(record);
2028    }
2029    Ok(())
2030}
2031
2032fn check_link(seq: u64, pre_hash: Option<&str>, previous: Option<(u64, &str)>) -> Result<()> {
2033    match previous {
2034        Some((previous_seq, previous_hash)) => {
2035            if seq != previous_seq + 1 {
2036                return Err(SdkError::InvalidPayload(
2037                    "seq must increase by 1".to_owned(),
2038                ));
2039            }
2040            if pre_hash != Some(previous_hash) {
2041                return Err(SdkError::InvalidPayload("pre_hash mismatch".to_owned()));
2042            }
2043        }
2044        None if seq != 1 => {
2045            return Err(SdkError::InvalidPayload("first seq must be 1".to_owned()));
2046        }
2047        None if pre_hash.is_some() => {
2048            return Err(SdkError::InvalidPayload(
2049                "first pre_hash must be null".to_owned(),
2050            ));
2051        }
2052        None => {}
2053    }
2054    Ok(())
2055}
2056
2057/// [`verify_server_record_chain`] over signed and redacted records.
2058pub fn verify_archive_chain(records: &[ArchiveRecord]) -> Result<()> {
2059    let mut previous: Option<&ArchiveRecord> = None;
2060    for record in records {
2061        verify_archive_record(record)?;
2062        check_link(
2063            record.seq(),
2064            record.pre_hash(),
2065            previous.map(|p| (p.seq(), p.hash())),
2066        )?;
2067        previous = Some(record);
2068    }
2069    Ok(())
2070}
2071
2072/// Archive verification steps 1–3 (Section 18): a gap-free chain from seq 1
2073/// to `last_seq` ending in `last_hash`, and a valid signature on every record
2074/// that is not redacted. Returns the sequence numbers of redacted records,
2075/// which verifiers must report. State replay (step 4) is the caller's.
2076pub fn verify_archive_records(
2077    manifest: &ArchiveManifest,
2078    records: &[ArchiveRecord],
2079) -> Result<Vec<u64>> {
2080    verify_archive_chain(records)?;
2081    match records.last() {
2082        Some(last) if last.seq() == manifest.last_seq && last.hash() == manifest.last_hash => {}
2083        _ => {
2084            return Err(SdkError::InvalidPayload(
2085                "archive does not end at last_seq / last_hash".to_owned(),
2086            ))
2087        }
2088    }
2089    let mut redacted = Vec::new();
2090    for record in records {
2091        if record.room_id() != manifest.room_id {
2092            return Err(SdkError::InvalidPayload(
2093                "record belongs to another room".to_owned(),
2094            ));
2095        }
2096        match record {
2097            ArchiveRecord::Redacted(r) => redacted.push(r.seq),
2098            ArchiveRecord::Signed(r) => validate_discourse_envelope(&r.envelope)?,
2099        }
2100    }
2101    Ok(redacted)
2102}
2103
2104/// Permission inputs for one actor in one room.
2105#[derive(Clone, Debug, Default, PartialEq, Eq)]
2106pub struct PermissionContext {
2107    pub role: Option<Role>,
2108    pub is_creator: bool,
2109    /// The actor may take the requested role by direct `room.join` (see [`can_join_directly`]).
2110    pub direct_join_allowed: bool,
2111}
2112
2113impl PermissionContext {
2114    pub fn for_role(role: Role) -> Self {
2115        Self {
2116            role: Some(role),
2117            ..Self::default()
2118        }
2119    }
2120
2121    pub fn creator(role: Option<Role>) -> Self {
2122        Self {
2123            role,
2124            is_creator: true,
2125            ..Self::default()
2126        }
2127    }
2128}
2129
2130/// Default sender roles for each kind. The creator passes every role check.
2131pub fn default_kind_roles(kind: TypeKind) -> &'static [Role] {
2132    match kind {
2133        TypeKind::Message => &[Role::Moderator, Role::Speaker],
2134        TypeKind::Signal => &ROLE_ORDER,
2135        TypeKind::Control => &[Role::Moderator],
2136    }
2137}
2138
2139/// Role check for one event type, using kind defaults and per-type overrides
2140/// from the room's type registry. State checks are separate.
2141pub fn can_submit_event(
2142    event_type: &str,
2143    context: &PermissionContext,
2144    registry: &TypeRegistry,
2145) -> bool {
2146    match event_type {
2147        event_type::ROOM_CREATE => true,
2148        event_type::ROOM_JOIN => {
2149            context.direct_join_allowed && !context.is_creator && context.role.is_none()
2150        }
2151        event_type::ROOM_JOIN_REQUEST => !context.is_creator && context.role.is_none(),
2152        // The creator is a member until the room ends and cannot leave.
2153        event_type::ROOM_LEAVE => !context.is_creator && context.role.is_some(),
2154        event_type::ROOM_UPDATE
2155        | event_type::ROOM_JOIN_REVIEW
2156        | event_type::ROOM_MEMBER_ROLE_UPDATE
2157        | event_type::ROOM_MEMBER_REMOVE
2158        | event_type::ROOM_CLOSE
2159        | event_type::ROOM_CANCEL
2160        | event_type::TYPE_DEFINE => context.is_creator || context.role == Some(Role::Moderator),
2161        event_type::MESSAGE_CREATE => {
2162            context.is_creator
2163                || matches!(context.role, Some(Role::Moderator) | Some(Role::Speaker))
2164        }
2165        custom => {
2166            let Some(def) = registry.get(custom) else {
2167                return false;
2168            };
2169            if def.status() == TypeStatus::Disabled {
2170                return false;
2171            }
2172            if context.is_creator {
2173                return true;
2174            }
2175            let Some(role) = context.role else {
2176                return false;
2177            };
2178            match &def.roles {
2179                Some(roles) => roles.contains(&role),
2180                None => default_kind_roles(def.kind).contains(&role),
2181            }
2182        }
2183    }
2184}
2185
2186pub fn can_write_in_state(event_type: &str, state: RoomState) -> bool {
2187    match state {
2188        RoomState::Scheduled => matches!(
2189            event_type,
2190            event_type::ROOM_JOIN_REQUEST
2191                | event_type::ROOM_JOIN
2192                | event_type::ROOM_JOIN_REVIEW
2193                | event_type::ROOM_MEMBER_ROLE_UPDATE
2194                | event_type::ROOM_MEMBER_REMOVE
2195                | event_type::ROOM_LEAVE
2196                | event_type::ROOM_UPDATE
2197                | event_type::TYPE_DEFINE
2198                | event_type::ROOM_CANCEL
2199        ),
2200        RoomState::Active => {
2201            event_type != event_type::ROOM_CREATE && event_type != event_type::ROOM_CANCEL
2202        }
2203        RoomState::Ended | RoomState::Cancelled => false,
2204    }
2205}
2206
2207pub fn can_accept_room_write(
2208    event_type: &str,
2209    state: RoomState,
2210    permission: &PermissionContext,
2211    registry: &TypeRegistry,
2212) -> bool {
2213    can_submit_event(event_type, permission, registry) && can_write_in_state(event_type, state)
2214}
2215
2216pub fn validate_room_write(
2217    event_type: &str,
2218    state: RoomState,
2219    permission: &PermissionContext,
2220    registry: &TypeRegistry,
2221) -> Result<()> {
2222    if can_accept_room_write(event_type, state, permission, registry) {
2223        Ok(())
2224    } else {
2225        Err(SdkError::PermissionDenied)
2226    }
2227}
2228
2229fn hash_canonical_json<T>(value: &T) -> Result<String>
2230where
2231    T: Serialize + ?Sized,
2232{
2233    let bytes = serde_jcs::to_vec(value).map_err(|err| SdkError::CanonicalJson(err.to_string()))?;
2234    let digest = Sha3_256::digest(bytes);
2235    Ok(URL_SAFE_NO_PAD.encode(digest))
2236}
2237
2238#[cfg(test)]
2239mod tests {
2240    use super::*;
2241    use crate::identity::AgentSigner;
2242    use serde_json::json;
2243
2244    const HOST: &str = "https://api.example.com";
2245
2246    fn signer(byte: u8) -> AgentSigner {
2247        AgentSigner::from_seed([byte; 32])
2248    }
2249
2250    fn packs() -> BTreeMap<String, Pack> {
2251        let document: PackDocument = serde_json::from_str(include_str!(
2252            "../../../docs/protocols/agent-discourse/1.0.packs.json"
2253        ))
2254        .unwrap();
2255        pack_map(&document)
2256    }
2257
2258    fn vectors() -> Value {
2259        serde_json::from_str(include_str!(
2260            "../../../docs/protocols/agent-discourse/1.0.vectors.json"
2261        ))
2262        .unwrap()
2263    }
2264
2265    fn finding_def() -> TypeDef {
2266        serde_json::from_value(json!({
2267            "type": "review.finding",
2268            "kind": "message",
2269            "title": "Review finding",
2270            "schema": {
2271                "type": "object",
2272                "required": ["severity", "summary"],
2273                "properties": {
2274                    "severity": { "type": "string", "enum": ["low", "medium", "high"] },
2275                    "summary": { "type": "string", "minLength": 1 }
2276                },
2277                "additionalProperties": false
2278            }
2279        }))
2280        .unwrap()
2281    }
2282
2283    fn room_payload() -> RoomCreatePayload {
2284        RoomCreatePayload::new(HOST, "Research room", Visibility::Public, 1000, 2000)
2285    }
2286
2287    fn message(
2288        author: &AgentSigner,
2289        room: &str,
2290        base_seq: u64,
2291        base_hash: &str,
2292        nonce: u64,
2293    ) -> Envelope<Value> {
2294        author
2295            .sign_event(discourse_event(
2296                event_type::MESSAGE_CREATE,
2297                author.agent_id(),
2298                100,
2299                nonce,
2300                room,
2301                base_seq,
2302                base_hash,
2303                json!({ "content_type": "text/plain", "content": "hi" }),
2304            ))
2305            .unwrap()
2306    }
2307
2308    #[test]
2309    fn kernel_defines_twelve_builtins_and_freshness_classes() {
2310        assert_eq!(BUILTIN_EVENT_TYPES.len(), 12);
2311        assert!(is_builtin_event_type(event_type::ROOM_JOIN_REQUEST));
2312        for kind in MEMBERSHIP_EVENT_TYPES {
2313            assert_eq!(builtin_event_class(kind), Some(RecordClass::Signal));
2314            assert!(!event_type_advances_head(kind, &[]));
2315            assert!(!event_requires_room_head(kind, &[]));
2316        }
2317        for kind in CONTRACT_EVENT_TYPES {
2318            assert_eq!(builtin_event_class(kind), Some(RecordClass::Contract));
2319            assert!(event_type_advances_head(kind, &[]));
2320            assert!(!event_requires_room_head(kind, &[]));
2321        }
2322        assert_eq!(
2323            builtin_event_class(event_type::ROOM_CREATE),
2324            Some(RecordClass::Genesis)
2325        );
2326        assert_eq!(record_class(event_type::ROOM_JOIN_REQUEST, &[]), None);
2327        assert!(event_requires_room_head(event_type::MESSAGE_CREATE, &[]));
2328        assert!(event_requires_room_head("unknown.custom", &[]));
2329        for code in [
2330            "host_mismatch",
2331            "type_conflict",
2332            "invalid_type_schema",
2333            "join_request_not_pending",
2334        ] {
2335            assert!(DISCOURSE_ERROR_CODES.contains(&code));
2336        }
2337    }
2338
2339    #[test]
2340    fn discourse_vectors_reproduce_chains_redaction_and_heads() {
2341        let vectors = vectors();
2342        let records: Vec<ServerRecord> =
2343            serde_json::from_value(vectors["records"].clone()).unwrap();
2344        for record in &records {
2345            verify_server_record(record).unwrap();
2346            validate_discourse_envelope(&record.envelope).unwrap();
2347        }
2348        verify_server_record_chain(&records).unwrap();
2349        let redacted: Vec<ArchiveRecord> =
2350            serde_json::from_value(vectors["redacted_records"].clone()).unwrap();
2351        assert!(redacted[2].is_redacted());
2352        verify_archive_chain(&redacted).unwrap();
2353        let manifest = ArchiveManifest {
2354            protocol: PROTOCOL.into(),
2355            kind: "room.archive".into(),
2356            room_id: vectors["room_id"].as_str().unwrap().into(),
2357            url: format!("{HOST}/v1/rooms/x"),
2358            generated_at: 0,
2359            last_seq: vectors["last_seq"].as_u64().unwrap(),
2360            last_hash: vectors["last_hash"].as_str().unwrap().into(),
2361            formats: BTreeMap::new(),
2362            packs: Vec::new(),
2363            extra: BTreeMap::new(),
2364        };
2365        let signed: Vec<ArchiveRecord> =
2366            records.iter().cloned().map(ArchiveRecord::Signed).collect();
2367        assert_eq!(
2368            verify_archive_records(&manifest, &signed).unwrap(),
2369            Vec::<u64>::new()
2370        );
2371        assert_eq!(
2372            verify_archive_records(&manifest, &redacted).unwrap(),
2373            vec![3]
2374        );
2375        let mut short = manifest.clone();
2376        short.last_hash = records[0].hash.clone();
2377        assert!(verify_archive_records(&short, &signed).is_err());
2378
2379        let create: RoomCreatePayload =
2380            serde_json::from_value(records[0].envelope.event.payload.clone()).unwrap();
2381        let registry =
2382            TypeRegistry::from_declarations(create.types.as_deref().unwrap(), &packs()).unwrap();
2383        let mut head = 0;
2384        for (record, expected) in records
2385            .iter()
2386            .zip(vectors["head_seq_after"].as_array().unwrap())
2387        {
2388            if event_advances_room_head(&record.envelope.event.kind, &registry) {
2389                head = record.seq;
2390            }
2391            assert_eq!(head, expected.as_u64().unwrap());
2392        }
2393    }
2394
2395    #[test]
2396    fn discourse_vectors_classes_and_patterns() {
2397        let vectors = vectors();
2398        let declarations: Vec<TypeDeclaration> =
2399            serde_json::from_value(vectors["freshness"]["registry"].clone()).unwrap();
2400        let registry = TypeRegistry::from_declarations(&declarations, &packs()).unwrap();
2401        let types: Vec<TypeDef> = registry.definitions().cloned().collect();
2402        let list = |name: &str| -> Vec<String> {
2403            serde_json::from_value(vectors["freshness"][name].clone()).unwrap()
2404        };
2405        let (head_checked, head_advancing) = (list("head_checked"), list("head_advancing"));
2406        for (kind, class) in vectors["freshness"]["classes"].as_object().unwrap() {
2407            let expected: RecordClass = serde_json::from_value(class.clone()).unwrap();
2408            assert_eq!(record_class(kind, &types), Some(expected), "{kind}");
2409            assert_eq!(
2410                event_requires_room_head(kind, &types),
2411                head_checked.contains(kind),
2412                "{kind}"
2413            );
2414            assert_eq!(
2415                event_type_advances_head(kind, &types),
2416                head_advancing.contains(kind),
2417                "{kind}"
2418            );
2419        }
2420        for case in vectors["freshness"]["base_checks"].as_array().unwrap() {
2421            let base_hash = "base-hash";
2422            let anchor = case["anchored"].as_bool().unwrap().then_some(base_hash);
2423            let result = validate_room_base(
2424                case["type"].as_str().unwrap(),
2425                &types,
2426                case["base_seq"].as_u64().unwrap(),
2427                base_hash,
2428                anchor,
2429                case["head_seq"].as_u64().unwrap(),
2430            );
2431            let outcome = match &result {
2432                Ok(()) => "ok",
2433                Err(error) => error.code().unwrap_or("other"),
2434            };
2435            assert_eq!(
2436                outcome,
2437                case["expected"].as_str().unwrap(),
2438                "{}",
2439                case["name"]
2440            );
2441        }
2442        for name in vectors["type_names"]["valid"].as_array().unwrap() {
2443            validate_custom_event_type_name(name.as_str().unwrap()).unwrap();
2444        }
2445        for name in vectors["type_names"]["invalid"].as_array().unwrap() {
2446            let name = name.as_str().unwrap();
2447            assert!(validate_custom_event_type_name(name).is_err(), "{name}");
2448        }
2449        for case in vectors["open_roles"]["effective"].as_array().unwrap() {
2450            let visibility: Visibility =
2451                serde_json::from_value(case["visibility"].clone()).unwrap();
2452            let policy: Option<RoomPolicy> = case
2453                .get("policy")
2454                .map(|p| serde_json::from_value(p.clone()).unwrap());
2455            let expected: Vec<Role> = serde_json::from_value(case["expected"].clone()).unwrap();
2456            assert_eq!(
2457                effective_open_roles(visibility, policy.as_ref()),
2458                expected,
2459                "{}",
2460                case["name"]
2461            );
2462        }
2463        for case in vectors["open_roles"]["invalid"].as_array().unwrap() {
2464            let visibility: Visibility =
2465                serde_json::from_value(case["visibility"].clone()).unwrap();
2466            let policy: RoomPolicy = serde_json::from_value(case["policy"].clone()).unwrap();
2467            let result = validate_room_policy(Some(&policy))
2468                .and_then(|()| validate_room_visibility_policy(visibility, Some(&policy)));
2469            assert!(result.is_err(), "{}", case["name"]);
2470        }
2471        for pattern in vectors["patterns"]["valid"].as_array().unwrap() {
2472            validate_portable_pattern(pattern.as_str().unwrap()).unwrap();
2473        }
2474        for pattern in vectors["patterns"]["invalid"].as_array().unwrap() {
2475            let pattern = pattern.as_str().unwrap();
2476            assert!(validate_portable_pattern(pattern).is_err(), "{pattern}");
2477        }
2478    }
2479
2480    #[test]
2481    fn registered_packs_follow_the_profile() {
2482        let packs = packs();
2483        assert_eq!(packs.len(), 5);
2484        for pack in packs.values() {
2485            for def in &pack.types {
2486                validate_type_def(def).unwrap();
2487            }
2488        }
2489        assert!(packs[pack_id::MODERATION]
2490            .types
2491            .iter()
2492            .any(|d| d.name == "claim.update"));
2493        assert!(!packs[pack_id::REALTIME]
2494            .types
2495            .iter()
2496            .any(|d| d.name == "session.candidate"));
2497    }
2498
2499    #[test]
2500    fn room_create_is_host_bound_and_closed() {
2501        let creator = signer(14);
2502        let envelope = creator
2503            .sign_event(room_create_event(
2504                creator.agent_id(),
2505                100,
2506                1,
2507                room_payload(),
2508            ))
2509            .unwrap();
2510        validate_discourse_envelope(&envelope).unwrap();
2511        validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").unwrap();
2512        validate_room_create_host(&envelope.event.payload, HOST).unwrap();
2513        assert_eq!(
2514            validate_room_create_host(&envelope.event.payload, "https://other.example")
2515                .unwrap_err()
2516                .code(),
2517            Some("host_mismatch")
2518        );
2519        let mut path_host = room_payload();
2520        path_host.host = format!("{HOST}/v1");
2521        assert!(validate_room_create_payload(&path_host).is_err());
2522        let with_room = creator
2523            .sign_event(
2524                room_create_event(creator.agent_id(), 100, 2, room_payload()).with_room_id("r1"),
2525            )
2526            .unwrap();
2527        assert!(validate_discourse_envelope(&with_room).is_err());
2528        let mut extra = room_create_event(creator.agent_id(), 100, 3, room_payload());
2529        extra.extra.insert("audience".into(), json!("x"));
2530        let extra = creator.sign_event(extra).unwrap();
2531        assert_eq!(
2532            validate_discourse_envelope(&extra).unwrap_err().code(),
2533            Some("invalid_event")
2534        );
2535    }
2536
2537    #[test]
2538    fn room_events_require_valid_room_ids_and_bases() {
2539        let author = signer(15);
2540        let no_room = author
2541            .sign_event(Event::new(
2542                PROTOCOL,
2543                event_type::MESSAGE_CREATE,
2544                author.agent_id(),
2545                1,
2546                1,
2547                json!({}),
2548            ))
2549            .unwrap();
2550        assert!(matches!(
2551            validate_discourse_envelope(&no_room),
2552            Err(SdkError::MissingRoomId)
2553        ));
2554        assert!(validate_discourse_envelope(&message(&author, "room/../x", 1, "h", 2)).is_err());
2555        validate_room_path(&message(&author, "room1", 1, "h", 3), "room1").unwrap();
2556        assert!(matches!(
2557            validate_room_path(&message(&author, "room1", 1, "h", 4), "room2"),
2558            Err(SdkError::RoomIdMismatch { .. })
2559        ));
2560        let mentions: Vec<AgentId> = (0..33).map(|i| signer(100 + i).agent_id()).collect();
2561        let many = author
2562            .sign_event(
2563                discourse_event(
2564                    event_type::MESSAGE_CREATE,
2565                    author.agent_id(),
2566                    1,
2567                    5,
2568                    "room1",
2569                    1,
2570                    "h",
2571                    json!({}),
2572                )
2573                .with_mentions(mentions.clone()),
2574            )
2575            .unwrap();
2576        assert!(validate_discourse_envelope(&many).is_err());
2577        let ok = author
2578            .sign_event(
2579                discourse_event(
2580                    event_type::MESSAGE_CREATE,
2581                    author.agent_id(),
2582                    1,
2583                    6,
2584                    "room1",
2585                    1,
2586                    "h",
2587                    json!({}),
2588                )
2589                .with_mentions(mentions[..32].to_vec()),
2590            )
2591            .unwrap();
2592        validate_discourse_envelope(&ok).unwrap();
2593    }
2594
2595    #[test]
2596    fn join_requests_are_signed_unanchored_and_embedded_by_reviews() {
2597        let moderator = signer(21);
2598        let applicant = signer(22);
2599        let room = "d8ftedhpqhsusbg001tg";
2600        let mut payload = RoomJoinRequestPayload::new(Role::Speaker);
2601        payload.perspective = Some("reviewer".into());
2602        let request = applicant
2603            .sign_event(room_join_request_event(
2604                applicant.agent_id(),
2605                1,
2606                1,
2607                room,
2608                payload,
2609            ))
2610            .unwrap();
2611        validate_discourse_envelope(&request).unwrap();
2612        let anchored = applicant
2613            .sign_event(
2614                room_join_request_event(
2615                    applicant.agent_id(),
2616                    1,
2617                    2,
2618                    room,
2619                    RoomJoinRequestPayload::new(Role::Speaker),
2620                )
2621                .with_room_head(1, "h"),
2622            )
2623            .unwrap();
2624        assert!(validate_discourse_envelope(&anchored).is_err());
2625
2626        let review = RoomJoinReviewPayload {
2627            request: request.clone(),
2628            decision: JoinDecision::Approve,
2629            role: Some(Role::Speaker),
2630            reason: None,
2631            extra: None,
2632        };
2633        let signed = moderator
2634            .sign_event(discourse_event(
2635                event_type::ROOM_JOIN_REVIEW,
2636                moderator.agent_id(),
2637                2,
2638                1,
2639                room,
2640                1,
2641                "h",
2642                review.clone(),
2643            ))
2644            .unwrap();
2645        validate_discourse_envelope(&signed).unwrap();
2646        validate_room_join_review_payload(&review, Some(room)).unwrap();
2647        assert!(validate_room_join_review_payload(
2648            &RoomJoinReviewPayload {
2649                role: None,
2650                ..review.clone()
2651            },
2652            Some(room)
2653        )
2654        .is_err());
2655        assert!(validate_room_join_review_payload(&review, Some("other")).is_err());
2656        let mut tampered = review;
2657        tampered.request.event.payload.role = Role::Moderator;
2658        assert!(validate_room_join_review_payload(&tampered, Some(room)).is_err());
2659    }
2660
2661    #[test]
2662    fn direct_join_follows_invites_and_open_roles() {
2663        let invited = signer(23).agent_id();
2664        let stranger = signer(24).agent_id();
2665        let policy = RoomPolicy {
2666            invites: Some(BTreeMap::from([(invited.clone(), Role::Moderator)])),
2667            open_roles: Some(vec![Role::Observer]),
2668            ..RoomPolicy::default()
2669        };
2670        assert!(can_join_directly(
2671            Visibility::Private,
2672            Some(&policy),
2673            &invited,
2674            Role::Moderator
2675        ));
2676        assert!(!can_join_directly(
2677            Visibility::Private,
2678            Some(&policy),
2679            &invited,
2680            Role::Speaker
2681        ));
2682        // Open roles apply to any visibility; a private room cannot list them.
2683        assert!(can_join_directly(
2684            Visibility::Restricted,
2685            Some(&policy),
2686            &stranger,
2687            Role::Observer
2688        ));
2689        assert!(validate_room_visibility_policy(Visibility::Private, Some(&policy)).is_err());
2690        validate_room_visibility_policy(Visibility::Restricted, Some(&policy)).unwrap();
2691        assert!(can_join_directly(
2692            Visibility::Public,
2693            Some(&policy),
2694            &stranger,
2695            Role::Observer
2696        ));
2697        assert!(!can_join_directly(
2698            Visibility::Public,
2699            Some(&policy),
2700            &stranger,
2701            Role::Speaker
2702        ));
2703        assert_eq!(
2704            effective_open_roles(Visibility::Public, None),
2705            vec![Role::Observer]
2706        );
2707        assert!(effective_open_roles(Visibility::Restricted, None).is_empty());
2708        assert!(!can_join_directly(
2709            Visibility::Private,
2710            None,
2711            &stranger,
2712            Role::Observer
2713        ));
2714        let no_observers = RoomPolicy {
2715            observer_allowed: Some(false),
2716            ..RoomPolicy::default()
2717        };
2718        assert!(effective_open_roles(Visibility::Public, Some(&no_observers)).is_empty());
2719        let bad = RoomPolicy {
2720            open_roles: Some(vec![Role::Moderator]),
2721            ..RoomPolicy::default()
2722        };
2723        assert!(validate_room_policy(Some(&bad)).is_err());
2724        let conflicting = RoomPolicy {
2725            observer_allowed: Some(false),
2726            open_roles: Some(vec![Role::Observer]),
2727            ..RoomPolicy::default()
2728        };
2729        assert!(validate_room_policy(Some(&conflicting)).is_err());
2730        assert!(
2731            serde_json::from_value::<RoomPolicy>(json!({ "moderator_agent_ids": [] })).is_err()
2732        );
2733    }
2734
2735    #[test]
2736    fn type_schemas_follow_the_portable_profile() {
2737        validate_type_schema_profile(&json!({
2738            "type": "object",
2739            "properties": { "a": { "$ref": "#/$defs/x" } },
2740            "$defs": { "x": { "type": "string", "pattern": "^a$" } }
2741        }))
2742        .unwrap();
2743        for schema in [
2744            json!({ "$ref": "https://example.com/schema.json" }),
2745            json!({ "$dynamicRef": "#x" }),
2746            json!({ "$schema": "http://json-schema.org/draft-07/schema#" }),
2747            json!({ "properties": { "a": { "pattern": "\\w" } } }),
2748            json!({ "patternProperties": { "\\d": {} } }),
2749            json!({ "allOf": [{ "items": { "pattern": "." } }] }),
2750        ] {
2751            assert_eq!(
2752                validate_type_schema_profile(&schema).unwrap_err().code(),
2753                Some("invalid_type_schema")
2754            );
2755        }
2756        let mut def = finding_def();
2757        def.schema = json!({ "type": "string", "pattern": "\\s" });
2758        assert!(TypeRegistry::new().define(def).is_err());
2759        // `format` is an annotation: a non-URI string is not a schema violation.
2760        let registry = TypeRegistry::from_declarations(
2761            &[TypeDeclaration::Import(PackImport {
2762                use_pack: Some(pack_id::CURATION.into()),
2763                ..PackImport::default()
2764            })],
2765            &packs(),
2766        )
2767        .unwrap();
2768        registry
2769            .validate_payload(
2770                "resource.add",
2771                &json!({ "resource_type": "web", "uri": "not a uri" }),
2772            )
2773            .unwrap();
2774    }
2775
2776    #[test]
2777    fn registry_imports_packs_and_rejects_conflicts() {
2778        let packs = packs();
2779        let import = |id: &str| {
2780            TypeDeclaration::Import(PackImport {
2781                use_pack: Some(id.into()),
2782                ..PackImport::default()
2783            })
2784        };
2785        let mut deliberation = PackImport {
2786            use_pack: Some(pack_id::DELIBERATION.into()),
2787            ..PackImport::default()
2788        };
2789        deliberation.overrides = Some(BTreeMap::from([(
2790            "poll.vote".into(),
2791            TypeOverride {
2792                roles: Some(vec![Role::Moderator, Role::Speaker, Role::Observer]),
2793                ..TypeOverride::default()
2794            },
2795        )]));
2796        let registry = TypeRegistry::from_declarations(
2797            &[
2798                import(pack_id::REACTIONS),
2799                TypeDeclaration::Import(deliberation),
2800                TypeDeclaration::Def(finding_def()),
2801            ],
2802            &packs,
2803        )
2804        .unwrap();
2805        assert_eq!(registry.len(), 6);
2806        assert!(can_submit_event(
2807            "poll.vote",
2808            &PermissionContext::for_role(Role::Observer),
2809            &registry
2810        ));
2811        let twice = TypeRegistry::from_declarations(
2812            &[
2813                TypeDeclaration::Def(finding_def()),
2814                TypeDeclaration::Def(finding_def()),
2815            ],
2816            &packs,
2817        );
2818        assert_eq!(twice.unwrap_err().code(), Some("type_conflict"));
2819        assert!(TypeRegistry::from_declarations(
2820            &[import(pack_id::REACTIONS), import(pack_id::REACTIONS)],
2821            &packs
2822        )
2823        .is_err());
2824        assert!(TypeRegistry::from_declarations(&[import("adp:unknown/1.0")], &packs).is_err());
2825        let mut subset = PackImport {
2826            use_pack: Some(pack_id::DELIBERATION.into()),
2827            types: Some(vec!["poll.vote".into(), "poll.vote".into()]),
2828            ..PackImport::default()
2829        };
2830        assert!(validate_pack_import(&subset).is_err());
2831        subset.types = Some(vec!["does.not.exist".into()]);
2832        assert!(
2833            TypeRegistry::from_declarations(&[TypeDeclaration::Import(subset)], &packs).is_err()
2834        );
2835        let mut registry = TypeRegistry::new();
2836        registry.define(finding_def()).unwrap();
2837        let mut signal = finding_def();
2838        signal.kind = TypeKind::Signal;
2839        assert_eq!(
2840            registry.define(signal).unwrap_err().code(),
2841            Some("type_conflict")
2842        );
2843        let external = PackImport {
2844            pack: Some("http://example.com/p.json".into()),
2845            digest: Some(format!("sha256:{}", "A".repeat(43))),
2846            ..PackImport::default()
2847        };
2848        assert!(validate_pack_import(&external).is_err());
2849        let digest = PackImport {
2850            pack: Some("https://example.com/p.json".into()),
2851            digest: Some("sha256:abc".into()),
2852            ..PackImport::default()
2853        };
2854        assert!(validate_pack_import(&digest).is_err());
2855    }
2856
2857    #[test]
2858    fn verifies_pack_digests() {
2859        let bytes = b"pack document bytes";
2860        let sha256 = format!("sha256:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)));
2861        let sha3 = format!(
2862            "sha3-256:{}",
2863            URL_SAFE_NO_PAD.encode(Sha3_256::digest(bytes))
2864        );
2865        verify_pack_digest(bytes, &sha256).unwrap();
2866        verify_pack_digest(bytes, &sha3).unwrap();
2867        assert!(verify_pack_digest(b"tampered", &sha256).is_err());
2868        assert!(verify_pack_digest(bytes, "md5:abc").is_err());
2869    }
2870
2871    #[test]
2872    fn permissions_follow_kinds_and_builtin_rules() {
2873        let registry = TypeRegistry::from_declarations(
2874            &[
2875                TypeDeclaration::Import(PackImport {
2876                    use_pack: Some(pack_id::REACTIONS.into()),
2877                    ..PackImport::default()
2878                }),
2879                TypeDeclaration::Import(PackImport {
2880                    use_pack: Some(pack_id::CURATION.into()),
2881                    ..PackImport::default()
2882                }),
2883            ],
2884            &packs(),
2885        )
2886        .unwrap();
2887        let observer = PermissionContext::for_role(Role::Observer);
2888        let speaker = PermissionContext::for_role(Role::Speaker);
2889        let moderator = PermissionContext::for_role(Role::Moderator);
2890        let creator = PermissionContext::creator(Some(Role::Observer));
2891        assert!(can_submit_event("reaction.create", &observer, &registry));
2892        assert!(can_submit_event("resource.add", &speaker, &registry));
2893        assert!(!can_submit_event("resource.add", &observer, &registry));
2894        assert!(can_submit_event("graph.update", &moderator, &registry));
2895        assert!(!can_submit_event("graph.update", &speaker, &registry));
2896        assert!(can_submit_event("graph.update", &creator, &registry));
2897        assert!(!can_submit_event("session.offer", &speaker, &registry));
2898        assert!(can_submit_event(
2899            event_type::ROOM_LEAVE,
2900            &observer,
2901            &registry
2902        ));
2903        assert!(!can_submit_event(
2904            event_type::ROOM_LEAVE,
2905            &PermissionContext::creator(Some(Role::Moderator)),
2906            &registry
2907        ));
2908        assert!(!can_submit_event(
2909            event_type::ROOM_JOIN,
2910            &PermissionContext::default(),
2911            &registry
2912        ));
2913        assert!(can_submit_event(
2914            event_type::ROOM_JOIN,
2915            &PermissionContext {
2916                direct_join_allowed: true,
2917                ..PermissionContext::default()
2918            },
2919            &registry
2920        ));
2921        assert!(can_submit_event(
2922            event_type::ROOM_JOIN_REQUEST,
2923            &PermissionContext::default(),
2924            &registry
2925        ));
2926        assert!(!can_submit_event(
2927            event_type::ROOM_JOIN_REQUEST,
2928            &speaker,
2929            &registry
2930        ));
2931        assert!(can_write_in_state(
2932            event_type::ROOM_JOIN_REQUEST,
2933            RoomState::Scheduled
2934        ));
2935        assert!(!can_write_in_state(
2936            event_type::ROOM_JOIN_REQUEST,
2937            RoomState::Ended
2938        ));
2939        assert!(!can_write_in_state(
2940            event_type::ROOM_CLOSE,
2941            RoomState::Scheduled
2942        ));
2943        assert!(!can_write_in_state(
2944            event_type::ROOM_CANCEL,
2945            RoomState::Active
2946        ));
2947        validate_room_write(
2948            event_type::MESSAGE_CREATE,
2949            RoomState::Active,
2950            &speaker,
2951            &registry,
2952        )
2953        .unwrap();
2954        assert!(validate_room_write(
2955            event_type::MESSAGE_CREATE,
2956            RoomState::Ended,
2957            &speaker,
2958            &registry
2959        )
2960        .is_err());
2961    }
2962
2963    #[test]
2964    fn validates_payload_shapes() {
2965        validate_message_create_payload(&MessageCreatePayload::text("hi")).unwrap();
2966        validate_message_create_payload(&MessageCreatePayload::new(
2967            "application/json",
2968            json!({ "a": 1 }),
2969        ))
2970        .unwrap();
2971        for content in [json!(1), json!([]), json!(null)] {
2972            assert!(validate_message_create_payload(&MessageCreatePayload::new(
2973                "application/json",
2974                content
2975            ))
2976            .is_err());
2977        }
2978        assert!(validate_room_update_payload(&RoomUpdatePayload::default()).is_err());
2979        assert!(serde_json::from_value::<RoomUpdatePayload>(json!({ "host": "x" })).is_err());
2980        assert!(
2981            serde_json::from_value::<RoomUpdatePayload>(json!({ "visibility": "private" }))
2982                .is_err()
2983        );
2984        let update = RoomUpdatePayload {
2985            start_time: Some(5),
2986            end_time: Some(5),
2987            ..RoomUpdatePayload::default()
2988        };
2989        assert!(validate_room_update_payload(&update).is_err());
2990        assert!(serde_json::from_value::<RoomJoinPayload>(
2991            json!({ "role": "speaker", "request_id": "jr" })
2992        )
2993        .is_err());
2994        let member = signer(41).agent_id();
2995        validate_room_member_remove_payload(&RoomMemberRemovePayload::new(member)).unwrap();
2996        let mut bad_create = room_payload();
2997        bad_create.topic = " ".into();
2998        assert!(validate_room_create_payload(&bad_create).is_err());
2999    }
3000
3001    #[test]
3002    fn builds_redacts_and_verifies_record_chains() {
3003        let author = signer(18);
3004        let create = author
3005            .sign_event(room_create_event(author.agent_id(), 100, 1, room_payload()))
3006            .unwrap();
3007        let first = build_server_record("room123", 1, None, 110, create).unwrap();
3008        let second = build_server_record(
3009            "room123",
3010            2,
3011            Some(first.hash.clone()),
3012            130,
3013            message(&author, "room123", 1, &first.hash, 2),
3014        )
3015        .unwrap();
3016        let first_value: ServerRecord =
3017            serde_json::from_value(serde_json::to_value(&first).unwrap()).unwrap();
3018        verify_server_record_chain(&[first_value.clone(), second.clone()]).unwrap();
3019        assert!(verify_server_record_chain(std::slice::from_ref(&second)).is_err());
3020        let redacted = redact_server_record(&second).unwrap();
3021        assert_eq!(redacted.envelope.kind, event_type::MESSAGE_CREATE);
3022        verify_archive_chain(&[
3023            ArchiveRecord::Signed(first_value.clone()),
3024            ArchiveRecord::Redacted(redacted),
3025        ])
3026        .unwrap();
3027        assert!(redact_server_record(&first_value).is_err());
3028    }
3029}