use std::collections::{BTreeMap, BTreeSet};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::Sha256;
use sha3::{Digest, Sha3_256};
use crate::error::{Result, SdkError};
use crate::identity::{verify_envelope, AgentId, Envelope, Event, MAX_SAFE_NONCE};
pub const PROTOCOL: &str = "agent-discourse/1.0";
pub mod event_type {
pub const ROOM_CREATE: &str = "room.create";
pub const ROOM_JOIN: &str = "room.join";
pub const ROOM_JOIN_REVIEW: &str = "room.join.review";
pub const ROOM_LEAVE: &str = "room.leave";
pub const ROOM_MEMBER_ROLE_UPDATE: &str = "room.member.role.update";
pub const ROOM_CLOSE: &str = "room.close";
pub const ROOM_CANCEL: &str = "room.cancel";
pub const TYPE_DEFINE: &str = "type.define";
pub const MESSAGE_CREATE: &str = "message.create";
}
pub const BUILTIN_EVENT_TYPES: [&str; 9] = [
event_type::ROOM_CREATE,
event_type::ROOM_JOIN,
event_type::ROOM_JOIN_REVIEW,
event_type::ROOM_LEAVE,
event_type::ROOM_MEMBER_ROLE_UPDATE,
event_type::ROOM_CLOSE,
event_type::ROOM_CANCEL,
event_type::TYPE_DEFINE,
event_type::MESSAGE_CREATE,
];
pub const RESERVED_TYPE_PREFIXES: [&str; 2] = ["room.", "type."];
pub mod pack_id {
pub const REACTIONS: &str = "adp:reactions/1.0";
pub const DELIBERATION: &str = "adp:deliberation/1.0";
pub const CURATION: &str = "adp:curation/1.0";
pub const MODERATION: &str = "adp:moderation/1.0";
pub const REALTIME: &str = "adp:realtime/1.0";
pub const REGISTERED: [&str; 5] = [REACTIONS, DELIBERATION, CURATION, MODERATION, REALTIME];
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RoomState {
Scheduled,
Active,
Ended,
Cancelled,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Visibility {
Public,
Restricted,
Private,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum Role {
Moderator,
Speaker,
Observer,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TypeKind {
Message,
Signal,
Control,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TypeStatus {
Active,
Deprecated,
Disabled,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum JoinRequestStatus {
Pending,
Approved,
Rejected,
Expired,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum JoinDecision {
Approve,
Reject,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct RoomCreatePayload {
pub topic: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agenda: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guidance: Option<String>,
pub visibility: Visibility,
pub start_time: i64,
pub end_time: i64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy: Option<RoomPolicy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub types: Vec<TypeDeclaration>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
impl RoomCreatePayload {
pub fn new(
topic: impl Into<String>,
visibility: Visibility,
start_time: i64,
end_time: i64,
) -> Self {
Self {
topic: topic.into(),
agenda: None,
guidance: None,
visibility,
start_time,
end_time,
tags: Vec::new(),
language: None,
policy: None,
types: Vec::new(),
extra: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct RoomPolicy {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub moderator_agent_ids: Vec<AgentId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_speakers: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observer_allowed: Option<bool>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
impl RoomPolicy {
pub fn new() -> Self {
Self::default()
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct TypeDef {
#[serde(rename = "type")]
pub name: String,
pub kind: TypeKind,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub schema: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub roles: Option<Vec<Role>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<TypeStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate_hint: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_payload_hint: Option<u64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
impl TypeDef {
pub fn status(&self) -> TypeStatus {
self.status.unwrap_or(TypeStatus::Active)
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct TypeOverride {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub roles: Option<Vec<Role>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<TypeStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate_hint: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_payload_hint: Option<u64>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct PackImport {
#[serde(rename = "use", default, skip_serializing_if = "Option::is_none")]
pub use_pack: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pack: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub types: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub overrides: BTreeMap<String, TypeOverride>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum TypeDeclaration {
Def(TypeDef),
Import(PackImport),
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Pack {
pub id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub types: Vec<TypeDef>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct PackDocument {
pub protocol: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub packs: Vec<Pack>,
}
pub fn pack_map(document: &PackDocument) -> BTreeMap<String, Pack> {
document
.packs
.iter()
.map(|pack| (pack.id.clone(), pack.clone()))
.collect()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ServerRecord<P = Value> {
pub room_id: String,
pub seq: u64,
#[serde(default)]
pub pre_hash: Option<String>,
pub hash: String,
pub received_at: i64,
pub envelope: Envelope<P>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServerRecordHashPayload {
pub room_id: String,
pub seq: u64,
pub pre_hash: Option<String>,
pub envelope_hash: String,
pub received_at: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RoomResponse {
pub id: String,
pub status: RoomState,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topic: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agenda: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guidance: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility: Option<Visibility>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_time: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_time: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy: Option<RoomPolicy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub types: Vec<TypeDef>,
pub seq: u64,
#[serde(default)]
pub pre_hash: Option<String>,
pub hash: String,
pub received_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head: Option<RoomHead>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub envelope: Option<Envelope<RoomCreatePayload>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct RoomHead {
pub seq: u64,
pub hash: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct RoomJoinPayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
pub role: Role,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perspective: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct RoomJoinRequestInput {
pub role: Role,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perspective: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
impl RoomJoinRequestInput {
pub fn new(role: Role) -> Self {
Self {
role,
perspective: None,
reason: None,
extra: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct RoomJoinRequest {
pub id: String,
pub room_id: String,
pub applicant: AgentId,
pub role: Role,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perspective: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub created_at: i64,
pub expires_at: i64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct RoomJoinRequestStatus {
pub request: RoomJoinRequest,
pub status: JoinRequestStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approved_role: Option<Role>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reviewed_by: Option<AgentId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reviewed_at: Option<i64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct AgentStatusInput {
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seen_seq: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seen_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claim_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activity: Option<String>,
pub expires_at: i64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
impl AgentStatusInput {
pub fn new(state: impl Into<String>, expires_at: i64) -> Self {
Self {
state: state.into(),
summary: None,
seen_seq: None,
seen_hash: None,
claim_id: None,
activity: None,
expires_at,
extra: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct AgentStatus {
pub room_id: String,
pub agent_id: AgentId,
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seen_seq: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seen_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claim_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activity: Option<String>,
pub expires_at: i64,
pub updated_at: i64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct AgentStatusListResponse {
pub statuses: Vec<AgentStatus>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct AgentStatusGetResponse {
pub status: AgentStatus,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct RoomJoinReviewPayload {
pub request: RoomJoinRequest,
pub decision: JoinDecision,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<Role>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct RoleUpdatePayload {
pub member: AgentId,
pub role: Role,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct ReasonPayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub references: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
pub type RoomLeavePayload = ReasonPayload;
pub type RoomClosePayload = ReasonPayload;
pub type RoomCancelPayload = ReasonPayload;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct MessageCreatePayload {
pub content_type: String,
pub content: Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub references: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
impl MessageCreatePayload {
pub fn new(content_type: impl Into<String>, content: Value) -> Self {
Self {
content_type: content_type.into(),
content,
references: Vec::new(),
extra: BTreeMap::new(),
}
}
pub fn text(text: impl Into<String>) -> Self {
Self::new("text/plain", Value::String(text.into()))
}
pub fn markdown(markdown: impl Into<String>) -> Self {
Self::new("text/markdown", Value::String(markdown.into()))
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProfileResolverMetadata {
pub mode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiscourseProtocolDiscovery {
pub protocol: String,
pub host: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub registered_packs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<ProfileResolverMetadata>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub endpoints: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ArchiveManifest {
pub protocol: String,
#[serde(rename = "type")]
pub kind: String,
pub host: String,
pub room_id: String,
pub url: String,
pub generated_at: i64,
pub event_count: u64,
pub first_seq: u64,
pub last_seq: u64,
pub last_hash: String,
pub events_sha3_256: String,
pub archive_root: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub formats: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
pub fn room_create_event(
actor: AgentId,
created_at: i64,
nonce: u64,
payload: RoomCreatePayload,
) -> Event<RoomCreatePayload> {
Event::new(
PROTOCOL,
event_type::ROOM_CREATE,
actor,
created_at,
nonce,
payload,
)
}
pub fn type_define_event(
actor: AgentId,
created_at: i64,
nonce: u64,
room_id: impl Into<String>,
base_seq: u64,
base_hash: impl Into<String>,
declaration: TypeDeclaration,
) -> Event<TypeDeclaration> {
Event::new(
PROTOCOL,
event_type::TYPE_DEFINE,
actor,
created_at,
nonce,
declaration,
)
.with_room_id(room_id)
.with_room_head(base_seq, base_hash)
}
pub fn discourse_event<P>(
kind: impl Into<String>,
actor: AgentId,
created_at: i64,
nonce: u64,
room_id: impl Into<String>,
base_seq: u64,
base_hash: impl Into<String>,
payload: P,
) -> Event<P> {
Event::new(PROTOCOL, kind, actor, created_at, nonce, payload)
.with_room_id(room_id)
.with_room_head(base_seq, base_hash)
}
pub fn is_builtin_event_type(event_type: &str) -> bool {
BUILTIN_EVENT_TYPES.contains(&event_type)
}
pub fn event_requires_room_id(event_type: &str) -> bool {
event_type != event_type::ROOM_CREATE
}
pub fn validate_discourse_envelope<P>(envelope: &Envelope<P>) -> Result<()>
where
P: Serialize,
{
verify_envelope(envelope)?;
let protocol = envelope.event.protocol.as_str();
if protocol != PROTOCOL {
return Err(SdkError::InvalidEventProtocol {
expected: PROTOCOL.to_owned(),
actual: envelope.event.protocol.clone(),
});
}
if envelope.event.kind == event_type::ROOM_CREATE {
if envelope.event.room_id.is_some() {
return Err(SdkError::InvalidPayload(
"room.create must not include room_id".to_owned(),
));
}
if envelope.event.base_seq.is_some()
|| envelope.event.base_hash.is_some()
|| !envelope.event.mentions.is_empty()
{
return Err(SdkError::InvalidPayload(
"room.create must not include base_seq, base_hash, or mentions".to_owned(),
));
}
} else {
if envelope.event.room_id.is_none() {
return Err(SdkError::MissingRoomId);
}
validate_room_head_precondition(&envelope.event)?;
}
Ok(())
}
pub fn validate_room_path<P>(envelope: &Envelope<P>, path_room_id: &str) -> Result<()> {
if envelope.event.kind == event_type::ROOM_CREATE {
if envelope.event.room_id.is_some() {
return Err(SdkError::InvalidPayload(
"room.create must not include room_id".to_owned(),
));
}
if envelope.event.base_seq.is_some()
|| envelope.event.base_hash.is_some()
|| !envelope.event.mentions.is_empty()
{
return Err(SdkError::InvalidPayload(
"room.create must not include base_seq, base_hash, or mentions".to_owned(),
));
}
return Ok(());
}
validate_room_head_precondition(&envelope.event)?;
match envelope.event.room_id.as_deref() {
Some(actual) if actual == path_room_id => Ok(()),
Some(actual) => Err(SdkError::RoomIdMismatch {
expected: path_room_id.to_owned(),
actual: actual.to_owned(),
}),
None => Err(SdkError::MissingRoomId),
}
}
fn validate_room_head_precondition<P>(event: &Event<P>) -> Result<()> {
match (event.base_seq, event.base_hash.as_deref()) {
(Some(seq), Some(hash)) if seq > 0 && seq <= MAX_SAFE_NONCE && !hash.trim().is_empty() => {
Ok(())
}
(Some(0), _) => Err(SdkError::InvalidPayload(
"base_seq must be a positive safe JSON integer".to_owned(),
)),
(Some(_), Some(hash)) if hash.trim().is_empty() => Err(SdkError::InvalidPayload(
"base_hash must not be empty".to_owned(),
)),
(Some(seq), _) if seq > MAX_SAFE_NONCE => Err(SdkError::InvalidPayload(
"base_seq must be a safe JSON integer".to_owned(),
)),
_ => Err(SdkError::InvalidPayload(
"room event requires base_seq and base_hash".to_owned(),
)),
}
}
pub fn validate_custom_event_type_name(name: &str) -> Result<()> {
let segments: Vec<&str> = name.split('.').collect();
let valid_shape = segments.len() >= 2 && segments.iter().all(|segment| {
let mut chars = segment.chars();
matches!(chars.next(), Some(first) if first.is_ascii_lowercase() || first.is_ascii_digit())
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
});
if !valid_shape {
return Err(SdkError::InvalidPayload(format!(
"invalid event type name: {name}"
)));
}
if is_builtin_event_type(name) {
return Err(SdkError::InvalidPayload(format!(
"{name} is a built-in event type"
)));
}
if RESERVED_TYPE_PREFIXES
.iter()
.any(|prefix| name.starts_with(prefix))
{
return Err(SdkError::InvalidPayload(format!(
"{name} uses a reserved type prefix"
)));
}
Ok(())
}
pub fn validate_type_def(def: &TypeDef) -> Result<()> {
validate_custom_event_type_name(&def.name)?;
if def.title.trim().is_empty() {
return Err(SdkError::InvalidPayload(
"type definition title must not be empty".to_owned(),
));
}
if !def.schema.is_object() {
return Err(SdkError::InvalidPayload(
"type definition schema must be a JSON Schema object".to_owned(),
));
}
compile_schema(&def.schema)?;
if matches!(&def.roles, Some(roles) if roles.is_empty()) {
return Err(SdkError::InvalidPayload(
"type definition roles must not be empty".to_owned(),
));
}
if matches!(def.rate_hint, Some(0)) || matches!(def.max_payload_hint, Some(0)) {
return Err(SdkError::InvalidPayload(
"type definition hints must be positive".to_owned(),
));
}
Ok(())
}
pub fn validate_pack_import(import: &PackImport) -> Result<()> {
match (&import.use_pack, &import.pack, &import.digest) {
(Some(id), None, None) => {
if !is_registered_pack_id(id) {
return Err(SdkError::InvalidPayload(format!(
"invalid registered pack id: {id}"
)));
}
}
(None, Some(_), Some(digest)) => {
if digest.trim().is_empty() {
return Err(SdkError::InvalidPayload(
"external pack digest must not be empty".to_owned(),
));
}
}
_ => {
return Err(SdkError::InvalidPayload(
"pack import requires either use, or pack with digest".to_owned(),
));
}
}
if matches!(&import.types, Some(types) if types.is_empty()) {
return Err(SdkError::InvalidPayload(
"pack import types subset must not be empty".to_owned(),
));
}
Ok(())
}
pub fn validate_type_declaration(declaration: &TypeDeclaration) -> Result<()> {
match declaration {
TypeDeclaration::Def(def) => validate_type_def(def),
TypeDeclaration::Import(import) => validate_pack_import(import),
}
}
fn is_registered_pack_id(id: &str) -> bool {
let Some(rest) = id.strip_prefix("adp:") else {
return false;
};
let Some((name, version)) = rest.split_once('/') else {
return false;
};
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& version.split('.').count() == 2
&& version
.split('.')
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
}
pub fn validate_room_create_payload(payload: &RoomCreatePayload) -> Result<()> {
if payload.topic.trim().is_empty() {
return Err(SdkError::InvalidPayload(
"room topic must not be empty".to_owned(),
));
}
if payload.start_time >= payload.end_time {
return Err(SdkError::InvalidPayload(
"start_time must be before end_time".to_owned(),
));
}
if let Some(policy) = &payload.policy {
if matches!(policy.max_speakers, Some(0)) {
return Err(SdkError::InvalidPayload(
"max_speakers must be a positive integer".to_owned(),
));
}
}
for declaration in &payload.types {
validate_type_declaration(declaration)?;
}
Ok(())
}
pub fn validate_message_create_payload(payload: &MessageCreatePayload) -> Result<()> {
if payload.content_type.trim().is_empty() {
return Err(SdkError::InvalidPayload(
"content_type must not be empty".to_owned(),
));
}
Ok(())
}
pub fn validate_room_join_payload(payload: &RoomJoinPayload) -> Result<()> {
if matches!(&payload.request_id, Some(request_id) if request_id.trim().is_empty()) {
return Err(SdkError::InvalidPayload(
"request_id must not be empty".to_owned(),
));
}
if payload.request_id.is_some() && payload.perspective.is_some() {
return Err(SdkError::InvalidPayload(
"room.join payload cannot include both request_id and perspective".to_owned(),
));
}
Ok(())
}
#[derive(Clone, Debug, Default)]
pub struct TypeRegistry {
types: BTreeMap<String, TypeDef>,
}
impl TypeRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn from_declarations(
declarations: &[TypeDeclaration],
packs: &BTreeMap<String, Pack>,
) -> Result<Self> {
let mut registry = Self::new();
for declaration in declarations {
registry.apply(declaration, packs)?;
}
Ok(registry)
}
pub fn apply(
&mut self,
declaration: &TypeDeclaration,
packs: &BTreeMap<String, Pack>,
) -> Result<()> {
match declaration {
TypeDeclaration::Def(def) => self.define(def.clone()),
TypeDeclaration::Import(import) => self.import(import, packs),
}
}
pub fn define(&mut self, def: TypeDef) -> Result<()> {
validate_type_def(&def)?;
self.types.insert(def.name.clone(), def);
Ok(())
}
fn import(&mut self, import: &PackImport, packs: &BTreeMap<String, Pack>) -> Result<()> {
validate_pack_import(import)?;
let reference = import
.use_pack
.as_deref()
.or(import.pack.as_deref())
.expect("validated pack import has a reference");
let pack = packs
.get(reference)
.ok_or_else(|| SdkError::PackUnavailable(reference.to_owned()))?;
let available: BTreeSet<&str> = pack.types.iter().map(|def| def.name.as_str()).collect();
if let Some(subset) = &import.types {
for name in subset {
if !available.contains(name.as_str()) {
return Err(SdkError::PackUnavailable(format!(
"type {name} is not in pack {reference}"
)));
}
}
}
for name in import.overrides.keys() {
let imported = import
.types
.as_ref()
.map(|subset| subset.contains(name))
.unwrap_or_else(|| available.contains(name.as_str()));
if !imported {
return Err(SdkError::InvalidPayload(format!(
"override target {name} is not imported from pack {reference}"
)));
}
}
for def in &pack.types {
if let Some(subset) = &import.types {
if !subset.contains(&def.name) {
continue;
}
}
let mut def = def.clone();
if let Some(over) = import.overrides.get(&def.name) {
if let Some(roles) = &over.roles {
def.roles = Some(roles.clone());
}
if let Some(instructions) = &over.instructions {
def.instructions = Some(instructions.clone());
}
if let Some(status) = over.status {
def.status = Some(status);
}
if let Some(rate_hint) = over.rate_hint {
def.rate_hint = Some(rate_hint);
}
if let Some(max_payload_hint) = over.max_payload_hint {
def.max_payload_hint = Some(max_payload_hint);
}
}
self.define(def)?;
}
Ok(())
}
pub fn get(&self, event_type: &str) -> Option<&TypeDef> {
self.types.get(event_type)
}
pub fn contains(&self, event_type: &str) -> bool {
self.types.contains_key(event_type)
}
pub fn len(&self) -> usize {
self.types.len()
}
pub fn is_empty(&self) -> bool {
self.types.is_empty()
}
pub fn definitions(&self) -> impl Iterator<Item = &TypeDef> {
self.types.values()
}
pub fn validate_payload(&self, event_type: &str, payload: &Value) -> Result<()> {
let def = self
.get(event_type)
.ok_or_else(|| SdkError::TypeNotDefined(event_type.to_owned()))?;
if def.status() == TypeStatus::Disabled {
return Err(SdkError::TypeDisabled(event_type.to_owned()));
}
let validator = compile_schema(&def.schema)?;
if let Err(error) = validator.validate(payload) {
return Err(SdkError::PayloadSchemaViolation(format!(
"{event_type}: {error}"
)));
}
Ok(())
}
}
pub fn validate_event_against_registry(
event_type: &str,
payload: &Value,
registry: &TypeRegistry,
) -> Result<()> {
if is_builtin_event_type(event_type) {
return Ok(());
}
registry.validate_payload(event_type, payload)
}
fn compile_schema(schema: &Value) -> Result<jsonschema::Validator> {
jsonschema::options()
.with_draft(jsonschema::Draft::Draft202012)
.build(schema)
.map_err(|err| SdkError::InvalidPayload(format!("invalid type schema: {err}")))
}
pub fn verify_pack_digest(bytes: &[u8], digest: &str) -> Result<()> {
let (algorithm, expected) = digest
.split_once(':')
.ok_or_else(|| SdkError::PackUnavailable(format!("invalid digest format: {digest}")))?;
let actual = match algorithm {
"sha256" => URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)),
"sha3-256" => URL_SAFE_NO_PAD.encode(Sha3_256::digest(bytes)),
_ => {
return Err(SdkError::PackUnavailable(format!(
"unsupported digest algorithm: {algorithm}"
)));
}
};
if actual == expected {
Ok(())
} else {
Err(SdkError::PackUnavailable("pack digest mismatch".to_owned()))
}
}
pub fn server_record_hash_payload(
room_id: &str,
seq: u64,
pre_hash: Option<&str>,
envelope_hash: &str,
received_at: i64,
) -> ServerRecordHashPayload {
ServerRecordHashPayload {
room_id: room_id.to_owned(),
seq,
pre_hash: pre_hash.map(str::to_owned),
envelope_hash: envelope_hash.to_owned(),
received_at,
}
}
pub fn server_record_hash(
room_id: &str,
seq: u64,
pre_hash: Option<&str>,
envelope_hash: &str,
received_at: i64,
) -> Result<String> {
hash_canonical_json(&server_record_hash_payload(
room_id,
seq,
pre_hash,
envelope_hash,
received_at,
))
}
pub fn build_server_record<P>(
room_id: impl Into<String>,
seq: u64,
pre_hash: Option<String>,
received_at: i64,
envelope: Envelope<P>,
) -> Result<ServerRecord<P>> {
let room_id = room_id.into();
let hash = server_record_hash(
&room_id,
seq,
pre_hash.as_deref(),
&envelope.hash,
received_at,
)
.expect("server record hash payload is always serializable");
Ok(ServerRecord {
room_id,
seq,
pre_hash,
hash,
received_at,
envelope,
})
}
pub fn verify_server_record<P>(record: &ServerRecord<P>) -> Result<()>
where
P: Serialize,
{
let expected = server_record_hash(
&record.room_id,
record.seq,
record.pre_hash.as_deref(),
&record.envelope.hash,
record.received_at,
)
.expect("server record hash payload is always serializable");
if record.hash == expected {
Ok(())
} else {
Err(SdkError::InvalidEventHash {
expected,
actual: record.hash.clone(),
})
}
}
pub fn verify_server_record_chain<P>(records: &[ServerRecord<P>]) -> Result<()>
where
P: Serialize,
{
let mut previous: Option<&ServerRecord<P>> = None;
for record in records {
verify_server_record(record)?;
if let Some(previous) = previous {
if record.seq != previous.seq + 1 {
return Err(SdkError::InvalidPayload(
"seq must increase by 1".to_owned(),
));
}
if record.pre_hash.as_deref() != Some(previous.hash.as_str()) {
return Err(SdkError::InvalidPayload("pre_hash mismatch".to_owned()));
}
} else if record.seq != 1 {
return Err(SdkError::InvalidPayload("first seq must be 1".to_owned()));
} else if record.pre_hash.is_some() {
return Err(SdkError::InvalidPayload(
"first pre_hash must be null".to_owned(),
));
}
previous = Some(record);
}
Ok(())
}
pub fn archive_events_digest<P>(records: &[ServerRecord<P>]) -> Result<String>
where
P: Serialize,
{
hash_canonical_json(records)
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PermissionContext {
pub role: Option<Role>,
pub is_creator: bool,
pub public_join_allowed: bool,
pub join_request_approved: bool,
}
impl PermissionContext {
pub fn for_role(role: Role) -> Self {
Self {
role: Some(role),
..Self::default()
}
}
pub fn creator(role: Option<Role>) -> Self {
Self {
role,
is_creator: true,
..Self::default()
}
}
}
pub fn default_kind_roles(kind: TypeKind) -> &'static [Role] {
match kind {
TypeKind::Message => &[Role::Moderator, Role::Speaker],
TypeKind::Signal => &[Role::Moderator, Role::Speaker, Role::Observer],
TypeKind::Control => &[Role::Moderator],
}
}
pub fn can_submit_event(
event_type: &str,
context: &PermissionContext,
registry: &TypeRegistry,
) -> bool {
match event_type {
event_type::ROOM_CREATE => true,
event_type::ROOM_JOIN => context.public_join_allowed || context.join_request_approved,
event_type::ROOM_LEAVE => context.is_creator || context.role.is_some(),
event_type::ROOM_JOIN_REVIEW
| event_type::ROOM_MEMBER_ROLE_UPDATE
| event_type::ROOM_CLOSE
| event_type::ROOM_CANCEL
| event_type::TYPE_DEFINE => context.is_creator || context.role == Some(Role::Moderator),
event_type::MESSAGE_CREATE => {
context.is_creator
|| matches!(context.role, Some(Role::Moderator) | Some(Role::Speaker))
}
custom => {
let Some(def) = registry.get(custom) else {
return false;
};
if def.status() == TypeStatus::Disabled {
return false;
}
if context.is_creator {
return true;
}
let Some(role) = context.role else {
return false;
};
match &def.roles {
Some(roles) => roles.contains(&role),
None => default_kind_roles(def.kind).contains(&role),
}
}
}
}
pub fn can_write_in_state(event_type: &str, state: RoomState) -> bool {
match state {
RoomState::Scheduled => matches!(
event_type,
event_type::ROOM_JOIN
| event_type::ROOM_JOIN_REVIEW
| event_type::ROOM_MEMBER_ROLE_UPDATE
| event_type::ROOM_LEAVE
| event_type::TYPE_DEFINE
| event_type::ROOM_CANCEL
),
RoomState::Active => {
event_type != event_type::ROOM_CREATE && event_type != event_type::ROOM_CANCEL
}
RoomState::Ended | RoomState::Cancelled => false,
}
}
pub fn can_accept_room_write(
event_type: &str,
state: RoomState,
permission: &PermissionContext,
registry: &TypeRegistry,
) -> bool {
can_submit_event(event_type, permission, registry) && can_write_in_state(event_type, state)
}
pub fn validate_room_write(
event_type: &str,
state: RoomState,
permission: &PermissionContext,
registry: &TypeRegistry,
) -> Result<()> {
if can_accept_room_write(event_type, state, permission, registry) {
Ok(())
} else {
Err(SdkError::PermissionDenied)
}
}
fn hash_canonical_json<T>(value: &T) -> Result<String>
where
T: Serialize + ?Sized,
{
let bytes = serde_jcs::to_vec(value).map_err(|err| SdkError::CanonicalJson(err.to_string()))?;
let digest = Sha3_256::digest(bytes);
Ok(URL_SAFE_NO_PAD.encode(digest))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::AgentSigner;
use serde::ser;
use serde_json::json;
struct FailingPayload;
impl Serialize for FailingPayload {
fn serialize<S>(&self, _serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
Err(ser::Error::custom("payload cannot be serialized"))
}
}
fn registered_packs() -> BTreeMap<String, Pack> {
let raw = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../docs/protocols/agent-discourse/1.0.packs.json"
))
.expect("read registered packs");
let document: PackDocument = serde_json::from_str(&raw).expect("parse registered packs");
assert_eq!(document.protocol, PROTOCOL);
pack_map(&document)
}
fn finding_def() -> TypeDef {
TypeDef {
name: "review.finding".to_owned(),
kind: TypeKind::Message,
title: "Review finding".to_owned(),
description: None,
schema: json!({
"type": "object",
"required": ["severity", "summary"],
"properties": {
"severity": {"type": "string", "enum": ["low", "medium", "high"]},
"summary": {"type": "string", "minLength": 1}
},
"additionalProperties": false
}),
roles: None,
instructions: None,
version: None,
status: None,
rate_hint: None,
max_payload_hint: None,
extra: BTreeMap::new(),
}
}
fn join_review_payload(applicant: AgentId) -> RoomJoinReviewPayload {
RoomJoinReviewPayload {
request: RoomJoinRequest {
id: "jr_01J8ZM7A3G2T9B4Q6X8R0N1P2Q".to_owned(),
room_id: "room1".to_owned(),
applicant,
role: Role::Speaker,
perspective: None,
reason: None,
created_at: 100,
expires_at: 200,
extra: BTreeMap::new(),
},
decision: JoinDecision::Approve,
role: Some(Role::Speaker),
reason: None,
extra: BTreeMap::new(),
}
}
#[test]
fn validates_room_create_without_room_id() {
let signer = AgentSigner::from_seed([14; 32]);
let payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
let envelope = signer
.sign_event(room_create_event(signer.agent_id(), 100, 1, payload))
.unwrap();
validate_discourse_envelope(&envelope).unwrap();
validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").unwrap();
}
#[test]
fn rejects_room_create_with_room_id() {
let signer = AgentSigner::from_seed([14; 32]);
let payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
let event = room_create_event(signer.agent_id(), 100, 1, payload)
.with_room_id("d8ftedhpqhsusbg001tg");
let envelope = signer.sign_event(event).unwrap();
assert!(validate_discourse_envelope(&envelope).is_err());
assert!(validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").is_err());
}
#[test]
fn rejects_room_event_without_room_id() {
let signer = AgentSigner::from_seed([15; 32]);
let event = Event::new(
PROTOCOL,
event_type::MESSAGE_CREATE,
signer.agent_id(),
100,
1,
MessageCreatePayload::text("hello"),
);
let envelope = signer.sign_event(event).unwrap();
assert!(validate_discourse_envelope(&envelope).is_err());
}
#[test]
fn rejects_invalid_discourse_envelope_signature() {
let signer = AgentSigner::from_seed([16; 32]);
let event = discourse_event(
event_type::MESSAGE_CREATE,
signer.agent_id(),
100,
1,
"room1",
1,
"room-head-hash",
MessageCreatePayload::text("hello"),
);
let mut envelope = signer.sign_event(event).unwrap();
envelope.signature = "invalid".to_owned();
assert!(validate_discourse_envelope(&envelope).is_err());
}
#[test]
fn validates_join_review_canonical_request() {
let moderator = AgentSigner::from_seed([21; 32]);
let applicant = AgentSigner::from_seed([22; 32]);
let payload = RoomJoinReviewPayload {
request: RoomJoinRequest {
id: "jr_01J8ZM7A3G2T9B4Q6X8R0N1P2Q".to_owned(),
room_id: "d8ftedhpqhsusbg001tg".to_owned(),
applicant: applicant.agent_id(),
role: Role::Speaker,
perspective: Some("distributed-systems reviewer".to_owned()),
reason: Some("I can cover replication and failure-mode tradeoffs.".to_owned()),
created_at: 1_779_757_210_000,
expires_at: 1_779_760_810_000,
extra: BTreeMap::new(),
},
decision: JoinDecision::Approve,
role: Some(Role::Speaker),
reason: Some("relevant expertise".to_owned()),
extra: BTreeMap::new(),
};
let envelope = moderator
.sign_event(discourse_event(
event_type::ROOM_JOIN_REVIEW,
moderator.agent_id(),
1_779_757_250_000,
1,
"d8ftedhpqhsusbg001tg",
1,
"room-head-hash",
payload.clone(),
))
.unwrap();
validate_discourse_envelope(&envelope).unwrap();
let value = serde_json::to_value(payload).unwrap();
assert!(value.get("member").is_none());
assert_eq!(
value["request"]["applicant"],
json!(applicant.agent_id().to_string())
);
assert_eq!(value["request"]["role"], json!("speaker"));
}
#[test]
fn role_serde_matches_spec() {
assert_eq!(
serde_json::to_string(&Role::Speaker).unwrap(),
"\"speaker\""
);
assert!(serde_json::from_str::<Role>("\"expert\"").is_err());
assert!(serde_json::from_str::<Role>("\"participant\"").is_err());
}
#[test]
fn room_response_retains_room_metadata_fields() {
let room: RoomResponse = serde_json::from_value(json!({
"id": "room1",
"status": "active",
"url": "https://api.example.test/v1/rooms/room1",
"topic": "Room",
"agenda": "Review the proposal",
"guidance": "Stay concise",
"visibility": "public",
"start_time": 1,
"end_time": 2,
"tags": ["review"],
"language": "en",
"seq": 7,
"pre_hash": "previous",
"hash": "current",
"received_at": 3
}))
.unwrap();
assert_eq!(room.agenda.as_deref(), Some("Review the proposal"));
assert_eq!(room.tags, vec!["review"]);
assert_eq!(room.language.as_deref(), Some("en"));
assert_eq!(
serde_json::to_value(&room).unwrap()["agenda"],
json!("Review the proposal")
);
}
#[test]
fn validates_custom_event_type_names() {
validate_custom_event_type_name("review.finding").unwrap();
validate_custom_event_type_name("poll.vote").unwrap();
assert!(validate_custom_event_type_name("freeform").is_err());
assert!(validate_custom_event_type_name("room.custom").is_err());
assert!(validate_custom_event_type_name("type.new").is_err());
assert!(validate_custom_event_type_name("message.create").is_err());
assert!(validate_custom_event_type_name("Bad.Name").is_err());
assert!(validate_custom_event_type_name(".finding").is_err());
assert!(validate_custom_event_type_name("review.").is_err());
assert!(validate_custom_event_type_name("review.finding!").is_err());
}
#[test]
fn materializes_registry_from_packs_and_inline_defs() {
let packs = registered_packs();
let declarations = vec![
TypeDeclaration::Import(PackImport {
use_pack: Some(pack_id::REACTIONS.to_owned()),
..PackImport::default()
}),
TypeDeclaration::Import(PackImport {
use_pack: Some(pack_id::DELIBERATION.to_owned()),
overrides: BTreeMap::from([(
"poll.vote".to_owned(),
TypeOverride {
roles: Some(vec![Role::Moderator, Role::Speaker, Role::Observer]),
..TypeOverride::default()
},
)]),
..PackImport::default()
}),
TypeDeclaration::Def(finding_def()),
];
let registry = TypeRegistry::from_declarations(&declarations, &packs).unwrap();
assert_eq!(registry.len(), 6);
assert!(registry.contains("reaction.create"));
assert!(registry.contains("poll.create"));
assert!(registry.contains("review.finding"));
let vote = registry.get("poll.vote").unwrap();
assert_eq!(
vote.roles.as_deref(),
Some([Role::Moderator, Role::Speaker, Role::Observer].as_slice())
);
let subset = TypeDeclaration::Import(PackImport {
use_pack: Some(pack_id::DELIBERATION.to_owned()),
types: Some(vec!["poll.create".to_owned(), "poll.vote".to_owned()]),
..PackImport::default()
});
let registry = TypeRegistry::from_declarations(&[subset], &packs).unwrap();
assert_eq!(registry.len(), 2);
assert!(!registry.contains("question.create"));
}
#[test]
fn rejects_bad_pack_imports() {
let packs = registered_packs();
let unknown = TypeDeclaration::Import(PackImport {
use_pack: Some("adp:unknown/1.0".to_owned()),
..PackImport::default()
});
assert!(TypeRegistry::from_declarations(&[unknown], &packs).is_err());
let bad_override = TypeDeclaration::Import(PackImport {
use_pack: Some(pack_id::REACTIONS.to_owned()),
overrides: BTreeMap::from([("poll.vote".to_owned(), TypeOverride::default())]),
..PackImport::default()
});
assert!(TypeRegistry::from_declarations(&[bad_override], &packs).is_err());
let both = PackImport {
use_pack: Some(pack_id::REACTIONS.to_owned()),
pack: Some("https://example.com/p.json".to_owned()),
digest: Some("sha256:abc".to_owned()),
..PackImport::default()
};
assert!(validate_pack_import(&both).is_err());
}
#[test]
fn latest_type_definition_wins() {
let mut registry = TypeRegistry::new();
registry.define(finding_def()).unwrap();
let mut redefined = finding_def();
redefined.status = Some(TypeStatus::Disabled);
registry.define(redefined).unwrap();
assert_eq!(
registry.get("review.finding").unwrap().status(),
TypeStatus::Disabled
);
}
#[test]
fn validates_custom_payloads_against_pack_schemas() {
let packs = registered_packs();
let registry = TypeRegistry::from_declarations(
&[TypeDeclaration::Import(PackImport {
use_pack: Some(pack_id::DELIBERATION.to_owned()),
..PackImport::default()
})],
&packs,
)
.unwrap();
let hash = "GDt8oHZQfQ3jl5ZUfyNxKZu07yAJdDYuaw_jf_JjLYs";
registry
.validate_payload(
"poll.vote",
&json!({"poll_event_id": hash, "option_ids": ["a"]}),
)
.unwrap();
assert!(registry
.validate_payload("poll.vote", &json!({"poll_event_id": hash}))
.is_err());
assert!(registry
.validate_payload("turn.update", &json!({}))
.is_err());
let mut disabled = TypeRegistry::new();
let mut def = finding_def();
def.status = Some(TypeStatus::Disabled);
disabled.define(def).unwrap();
assert!(disabled
.validate_payload(
"review.finding",
&json!({"severity": "high", "summary": "s"})
)
.is_err());
}
#[test]
fn applies_kind_based_permissions() {
let packs = registered_packs();
let registry = TypeRegistry::from_declarations(
&[
TypeDeclaration::Import(PackImport {
use_pack: Some(pack_id::REACTIONS.to_owned()),
..PackImport::default()
}),
TypeDeclaration::Import(PackImport {
use_pack: Some(pack_id::DELIBERATION.to_owned()),
overrides: BTreeMap::from([(
"poll.vote".to_owned(),
TypeOverride {
roles: Some(vec![Role::Moderator, Role::Speaker, Role::Observer]),
..TypeOverride::default()
},
)]),
..PackImport::default()
}),
TypeDeclaration::Import(PackImport {
use_pack: Some(pack_id::CURATION.to_owned()),
..PackImport::default()
}),
],
&packs,
)
.unwrap();
let observer = PermissionContext::for_role(Role::Observer);
let speaker = PermissionContext::for_role(Role::Speaker);
let moderator = PermissionContext::for_role(Role::Moderator);
let creator = PermissionContext::creator(Some(Role::Observer));
assert!(can_submit_event("reaction.create", &observer, ®istry));
assert!(can_submit_event("poll.vote", &observer, ®istry));
assert!(can_submit_event("resource.add", &speaker, ®istry));
assert!(!can_submit_event("resource.add", &observer, ®istry));
assert!(can_submit_event("graph.update", &moderator, ®istry));
assert!(!can_submit_event("graph.update", &speaker, ®istry));
assert!(can_submit_event("graph.update", &creator, ®istry));
assert!(can_submit_event(
event_type::ROOM_CREATE,
&PermissionContext::default(),
®istry
));
assert!(can_submit_event(
event_type::MESSAGE_CREATE,
&creator,
®istry
));
assert!(!can_submit_event("session.offer", &speaker, ®istry));
assert!(can_submit_event(
event_type::ROOM_JOIN_REVIEW,
&moderator,
®istry
));
assert!(!can_submit_event(
event_type::ROOM_JOIN_REVIEW,
&speaker,
®istry
));
assert!(can_submit_event(
event_type::ROOM_MEMBER_ROLE_UPDATE,
&moderator,
®istry
));
assert!(can_submit_event(
event_type::ROOM_CANCEL,
&moderator,
®istry
));
assert!(can_submit_event(
event_type::TYPE_DEFINE,
&moderator,
®istry
));
assert!(!can_submit_event(
event_type::TYPE_DEFINE,
&speaker,
®istry
));
assert!(can_submit_event(
event_type::MESSAGE_CREATE,
&speaker,
®istry
));
assert!(!can_submit_event(
event_type::MESSAGE_CREATE,
&observer,
®istry
));
assert!(can_submit_event(
event_type::ROOM_LEAVE,
&observer,
®istry
));
assert!(!can_submit_event(
event_type::ROOM_JOIN,
&observer,
®istry
));
let approved = PermissionContext {
join_request_approved: true,
..PermissionContext::default()
};
assert!(can_submit_event(
event_type::ROOM_JOIN,
&approved,
®istry
));
}
#[test]
fn applies_state_restrictions() {
let registry = TypeRegistry::new();
let speaker = PermissionContext::for_role(Role::Speaker);
let moderator = PermissionContext::for_role(Role::Moderator);
assert!(can_accept_room_write(
event_type::MESSAGE_CREATE,
RoomState::Active,
&speaker,
®istry
));
assert!(!can_accept_room_write(
event_type::MESSAGE_CREATE,
RoomState::Scheduled,
&speaker,
®istry
));
assert!(can_write_in_state(
event_type::ROOM_JOIN_REVIEW,
RoomState::Scheduled
));
assert!(can_write_in_state(
event_type::ROOM_MEMBER_ROLE_UPDATE,
RoomState::Scheduled
));
assert!(can_write_in_state(
event_type::ROOM_LEAVE,
RoomState::Scheduled
));
assert!(can_write_in_state(
event_type::TYPE_DEFINE,
RoomState::Scheduled
));
assert!(can_write_in_state(
event_type::ROOM_CANCEL,
RoomState::Scheduled
));
assert!(!can_write_in_state(
event_type::ROOM_CLOSE,
RoomState::Scheduled
));
assert!(can_accept_room_write(
event_type::TYPE_DEFINE,
RoomState::Scheduled,
&moderator,
®istry
));
assert!(!can_write_in_state("reaction.create", RoomState::Ended));
assert!(!can_write_in_state(
event_type::ROOM_LEAVE,
RoomState::Ended
));
assert!(!can_write_in_state(
event_type::ROOM_JOIN,
RoomState::Cancelled
));
assert!(can_write_in_state(
event_type::ROOM_CLOSE,
RoomState::Active
));
assert!(!can_write_in_state(
event_type::ROOM_CANCEL,
RoomState::Active
));
}
#[test]
fn validates_room_creation_payloads() {
let mut payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
payload.policy = Some(RoomPolicy {
max_speakers: Some(2),
..RoomPolicy::default()
});
payload.guidance = Some("Cite sources.".to_owned());
payload.types = vec![
TypeDeclaration::Import(PackImport {
use_pack: Some(pack_id::REACTIONS.to_owned()),
..PackImport::default()
}),
TypeDeclaration::Def(finding_def()),
];
validate_room_create_payload(&payload).unwrap();
let empty_topic = RoomCreatePayload::new(" ", Visibility::Public, 1000, 2000);
assert!(validate_room_create_payload(&empty_topic).is_err());
let invalid_time = RoomCreatePayload::new("Research room", Visibility::Public, 2000, 1000);
assert!(validate_room_create_payload(&invalid_time).is_err());
let mut zero_speakers =
RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
zero_speakers.policy = Some(RoomPolicy {
max_speakers: Some(0),
..RoomPolicy::default()
});
assert!(validate_room_create_payload(&zero_speakers).is_err());
let mut reserved = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
let mut bad_def = finding_def();
bad_def.name = "room.custom".to_owned();
reserved.types = vec![TypeDeclaration::Def(bad_def)];
assert!(validate_room_create_payload(&reserved).is_err());
}
#[test]
fn verifies_pack_digests() {
let bytes = b"pack document bytes";
let digest = format!("sha256:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(bytes)));
verify_pack_digest(bytes, &digest).unwrap();
let digest = format!(
"sha3-256:{}",
URL_SAFE_NO_PAD.encode(Sha3_256::digest(bytes))
);
verify_pack_digest(bytes, &digest).unwrap();
assert!(verify_pack_digest(b"tampered", &digest).is_err());
assert!(verify_pack_digest(bytes, "md5:abc").is_err());
assert!(verify_pack_digest(bytes, "not-a-digest").is_err());
}
#[test]
fn hash_canonical_json_rejects_unserializable_values() {
assert!(hash_canonical_json(&FailingPayload).is_err());
}
#[test]
fn builds_and_verifies_server_record_chains() {
let signer = AgentSigner::from_seed([18; 32]);
let envelope1 = signer
.sign_event(Event::new(
PROTOCOL,
event_type::ROOM_CREATE,
signer.agent_id(),
100,
1,
json!({
"topic": "Research room",
"visibility": "public",
"start_time": 1000,
"end_time": 2000
}),
))
.unwrap();
let record1 = build_server_record("room123", 1, None, 110, envelope1).unwrap();
let envelope2 = signer
.sign_event(discourse_event(
event_type::MESSAGE_CREATE,
signer.agent_id(),
120,
2,
"room123",
1,
record1.hash.clone(),
json!({"content_type": "text/plain", "content": "hello"}),
))
.unwrap();
let record2 =
build_server_record("room123", 2, Some(record1.hash.clone()), 130, envelope2).unwrap();
assert_eq!(
record1.hash,
server_record_hash("room123", 1, None, &record1.envelope.hash, 110).unwrap()
);
verify_server_record(&record1).unwrap();
verify_server_record_chain(&[record1.clone(), record2.clone()]).unwrap();
assert_eq!(
archive_events_digest(&[record1.clone(), record2.clone()])
.unwrap()
.len(),
43
);
assert!(verify_server_record_chain(&[record2]).is_err());
let broken = ServerRecord {
pre_hash: Some("bad".to_owned()),
..record1
};
assert!(verify_server_record_chain(&[broken]).is_err());
}
#[test]
fn type_declaration_serde_round_trips() {
let inline: TypeDeclaration = serde_json::from_value(json!({
"type": "review.finding",
"kind": "message",
"title": "Review finding",
"schema": {"type": "object"}
}))
.unwrap();
assert_eq!(
serde_json::to_value(&inline).unwrap()["type"],
json!("review.finding")
);
let import: TypeDeclaration = serde_json::from_value(json!({
"use": "adp:reactions/1.0",
"overrides": {"reaction.create": {"status": "deprecated"}}
}))
.unwrap();
assert_eq!(
serde_json::to_value(&import).unwrap()["use"],
json!("adp:reactions/1.0")
);
validate_type_declaration(&import).unwrap();
}
#[test]
fn constructors_and_helpers() {
assert_eq!(RoomPolicy::new(), RoomPolicy::default());
let input = RoomJoinRequestInput::new(Role::Observer);
assert_eq!(input.role, Role::Observer);
assert!(input.perspective.is_none());
assert_eq!(
MessageCreatePayload::markdown("# title").content_type,
"text/markdown"
);
assert_eq!(
MessageCreatePayload::text("hi").content,
Value::String("hi".to_owned())
);
assert!(event_requires_room_id(event_type::MESSAGE_CREATE));
assert!(!event_requires_room_id(event_type::ROOM_CREATE));
let signer = AgentSigner::from_seed([40; 32]);
let event = type_define_event(
signer.agent_id(),
1,
1,
"room1",
1,
"room-head-hash",
TypeDeclaration::Def(finding_def()),
);
assert_eq!(event.protocol, PROTOCOL);
assert_eq!(event.kind, event_type::TYPE_DEFINE);
assert_eq!(event.room_id.as_deref(), Some("room1"));
}
#[test]
fn validate_discourse_envelope_checks_protocol() {
let signer = AgentSigner::from_seed([41; 32]);
let event = Event::new(
"wrong-protocol/1.0",
event_type::MESSAGE_CREATE,
signer.agent_id(),
100,
1,
MessageCreatePayload::text("hi"),
)
.with_room_id("room1");
let envelope = signer.sign_event(event).unwrap();
assert!(validate_discourse_envelope(&envelope).is_err());
}
#[test]
fn validate_discourse_envelope_covers_payload_instantiations() {
let signer = AgentSigner::from_seed([44; 32]);
let room_payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
let mut invalid_room_create = signer
.sign_event(room_create_event(
signer.agent_id(),
100,
1,
room_payload.clone(),
))
.unwrap();
invalid_room_create.signature = "invalid".to_owned();
assert!(validate_discourse_envelope(&invalid_room_create).is_err());
let wrong_room_protocol = signer
.sign_event(Event::new(
"wrong-protocol/1.0",
event_type::ROOM_CREATE,
signer.agent_id(),
100,
2,
room_payload.clone(),
))
.unwrap();
assert!(validate_discourse_envelope(&wrong_room_protocol).is_err());
let missing_room_id = signer
.sign_event(Event::new(
PROTOCOL,
event_type::MESSAGE_CREATE,
signer.agent_id(),
100,
3,
room_payload.clone(),
))
.unwrap();
assert!(validate_discourse_envelope(&missing_room_id).is_err());
let room_payload_as_message = signer
.sign_event(
Event::new(
PROTOCOL,
event_type::MESSAGE_CREATE,
signer.agent_id(),
100,
4,
room_payload,
)
.with_room_id("room1")
.with_room_head(1, "room-head-hash"),
)
.unwrap();
validate_discourse_envelope(&room_payload_as_message).unwrap();
let message_payload_as_room = signer
.sign_event(Event::new(
PROTOCOL,
event_type::ROOM_CREATE,
signer.agent_id(),
100,
5,
MessageCreatePayload::text("hi"),
))
.unwrap();
validate_discourse_envelope(&message_payload_as_room).unwrap();
let message_payload_with_room = signer
.sign_event(
Event::new(
PROTOCOL,
event_type::ROOM_CREATE,
signer.agent_id(),
100,
6,
MessageCreatePayload::text("hi"),
)
.with_room_id("room1"),
)
.unwrap();
assert!(validate_discourse_envelope(&message_payload_with_room).is_err());
let review_payload = join_review_payload(signer.agent_id());
let mut invalid_review = signer
.sign_event(discourse_event(
event_type::ROOM_JOIN_REVIEW,
signer.agent_id(),
100,
7,
"room1",
1,
"room-head-hash",
review_payload.clone(),
))
.unwrap();
invalid_review.signature = "invalid".to_owned();
assert!(validate_discourse_envelope(&invalid_review).is_err());
let wrong_review_protocol = signer
.sign_event(
Event::new(
"wrong-protocol/1.0",
event_type::ROOM_JOIN_REVIEW,
signer.agent_id(),
100,
8,
review_payload.clone(),
)
.with_room_id("room1"),
)
.unwrap();
assert!(validate_discourse_envelope(&wrong_review_protocol).is_err());
let missing_review_room = signer
.sign_event(Event::new(
PROTOCOL,
event_type::ROOM_JOIN_REVIEW,
signer.agent_id(),
100,
9,
review_payload.clone(),
))
.unwrap();
assert!(validate_discourse_envelope(&missing_review_room).is_err());
let review_payload_as_room = signer
.sign_event(Event::new(
PROTOCOL,
event_type::ROOM_CREATE,
signer.agent_id(),
100,
10,
review_payload,
))
.unwrap();
validate_discourse_envelope(&review_payload_as_room).unwrap();
}
#[test]
fn validate_room_path_matches_and_rejects() {
let signer = AgentSigner::from_seed([42; 32]);
let in_room = signer
.sign_event(discourse_event(
event_type::MESSAGE_CREATE,
signer.agent_id(),
100,
1,
"room1",
1,
"room-head-hash",
MessageCreatePayload::text("hi"),
))
.unwrap();
validate_room_path(&in_room, "room1").unwrap();
assert!(validate_room_path(&in_room, "room2").is_err());
let no_room = signer
.sign_event(Event::new(
PROTOCOL,
event_type::MESSAGE_CREATE,
signer.agent_id(),
100,
1,
MessageCreatePayload::text("hi"),
))
.unwrap();
assert!(validate_room_path(&no_room, "room1").is_err());
}
#[test]
fn validate_room_path_covers_payload_instantiations() {
let signer = AgentSigner::from_seed([45; 32]);
let room_payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
let in_room = signer
.sign_event(
Event::new(
PROTOCOL,
event_type::MESSAGE_CREATE,
signer.agent_id(),
100,
1,
room_payload.clone(),
)
.with_room_id("room1")
.with_room_head(1, "room-head-hash"),
)
.unwrap();
validate_room_path(&in_room, "room1").unwrap();
assert!(validate_room_path(&in_room, "room2").is_err());
let missing_room = signer
.sign_event(Event::new(
PROTOCOL,
event_type::MESSAGE_CREATE,
signer.agent_id(),
100,
2,
room_payload,
))
.unwrap();
assert!(validate_room_path(&missing_room, "room1").is_err());
let message_room_create = signer
.sign_event(Event::new(
PROTOCOL,
event_type::ROOM_CREATE,
signer.agent_id(),
100,
3,
MessageCreatePayload::text("hi"),
))
.unwrap();
validate_room_path(&message_room_create, "room1").unwrap();
let message_room_create_with_room = signer
.sign_event(
Event::new(
PROTOCOL,
event_type::ROOM_CREATE,
signer.agent_id(),
100,
4,
MessageCreatePayload::text("hi"),
)
.with_room_id("room1"),
)
.unwrap();
assert!(validate_room_path(&message_room_create_with_room, "room1").is_err());
}
#[test]
fn validate_type_def_rejects_bad_shapes() {
let mut empty_title = finding_def();
empty_title.title = " ".to_owned();
assert!(validate_type_def(&empty_title).is_err());
assert!(TypeRegistry::new().define(empty_title).is_err());
let mut not_object = finding_def();
not_object.schema = json!("nope");
assert!(validate_type_def(¬_object).is_err());
let mut invalid_schema = finding_def();
invalid_schema.schema = json!({"type": 123});
assert!(validate_type_def(&invalid_schema).is_err());
let mut empty_roles = finding_def();
empty_roles.roles = Some(vec![]);
assert!(validate_type_def(&empty_roles).is_err());
let mut zero_rate = finding_def();
zero_rate.rate_hint = Some(0);
assert!(validate_type_def(&zero_rate).is_err());
let mut zero_payload = finding_def();
zero_payload.max_payload_hint = Some(0);
assert!(validate_type_def(&zero_payload).is_err());
}
#[test]
fn validate_pack_import_covers_each_arm() {
validate_pack_import(&PackImport {
use_pack: Some(pack_id::REACTIONS.to_owned()),
..PackImport::default()
})
.unwrap();
validate_pack_import(&PackImport {
pack: Some("https://example.com/p.json".to_owned()),
digest: Some("sha256:abc".to_owned()),
..PackImport::default()
})
.unwrap();
assert!(validate_pack_import(&PackImport {
pack: Some("https://example.com/p.json".to_owned()),
digest: Some(" ".to_owned()),
..PackImport::default()
})
.is_err());
assert!(validate_pack_import(&PackImport::default()).is_err());
assert!(validate_pack_import(&PackImport {
use_pack: Some(pack_id::REACTIONS.to_owned()),
types: Some(vec![]),
..PackImport::default()
})
.is_err());
let invalid = TypeDeclaration::Import(PackImport {
use_pack: Some(pack_id::REACTIONS.to_owned()),
pack: Some("https://example.com/p.json".to_owned()),
digest: Some("sha256:abc".to_owned()),
..PackImport::default()
});
assert!(TypeRegistry::from_declarations(&[invalid], ®istered_packs()).is_err());
}
#[test]
fn registered_pack_id_shape() {
assert!(validate_pack_import(&PackImport {
use_pack: Some("not-prefixed".to_owned()),
..PackImport::default()
})
.is_err());
assert!(validate_pack_import(&PackImport {
use_pack: Some("adp:no-slash".to_owned()),
..PackImport::default()
})
.is_err());
assert!(validate_pack_import(&PackImport {
use_pack: Some("adp:bad_name/1.0".to_owned()),
..PackImport::default()
})
.is_err());
assert!(validate_pack_import(&PackImport {
use_pack: Some("adp:bad/1.x".to_owned()),
..PackImport::default()
})
.is_err());
assert!(validate_pack_import(&PackImport {
use_pack: Some("adp:bad/1.2.3".to_owned()),
..PackImport::default()
})
.is_err());
}
#[test]
fn validate_message_create_payload_checks_content_type() {
validate_message_create_payload(&MessageCreatePayload::text("hi")).unwrap();
let mut empty = MessageCreatePayload::text("hi");
empty.content_type = " ".to_owned();
assert!(validate_message_create_payload(&empty).is_err());
}
#[test]
fn import_rejects_unknown_subset_and_applies_all_overrides() {
let packs = registered_packs();
let missing_subset = TypeDeclaration::Import(PackImport {
use_pack: Some(pack_id::REACTIONS.to_owned()),
types: Some(vec!["does.not.exist".to_owned()]),
..PackImport::default()
});
assert!(TypeRegistry::from_declarations(&[missing_subset], &packs).is_err());
let missing_pack = TypeDeclaration::Import(PackImport {
use_pack: Some("adp:missing/1.0".to_owned()),
..PackImport::default()
});
assert!(TypeRegistry::from_declarations(&[missing_pack], &packs).is_err());
let hidden_override = TypeDeclaration::Import(PackImport {
use_pack: Some(pack_id::DELIBERATION.to_owned()),
types: Some(vec!["poll.create".to_owned()]),
overrides: BTreeMap::from([("poll.vote".to_owned(), TypeOverride::default())]),
..PackImport::default()
});
assert!(TypeRegistry::from_declarations(&[hidden_override], &packs).is_err());
let empty_pack = BTreeMap::from([(
"adp:empty/1.0".to_owned(),
Pack {
id: "adp:empty/1.0".to_owned(),
title: "Empty pack".to_owned(),
description: None,
types: Vec::new(),
extra: BTreeMap::new(),
},
)]);
let empty_import = TypeDeclaration::Import(PackImport {
use_pack: Some("adp:empty/1.0".to_owned()),
..PackImport::default()
});
assert!(
TypeRegistry::from_declarations(&[empty_import], &empty_pack)
.unwrap()
.is_empty()
);
let overridden = TypeDeclaration::Import(PackImport {
use_pack: Some(pack_id::REACTIONS.to_owned()),
overrides: BTreeMap::from([(
"reaction.create".to_owned(),
TypeOverride {
roles: Some(vec![Role::Moderator]),
instructions: Some("be concise".to_owned()),
status: Some(TypeStatus::Deprecated),
rate_hint: Some(5),
max_payload_hint: Some(2048),
},
)]),
..PackImport::default()
});
let registry = TypeRegistry::from_declarations(&[overridden], &packs).unwrap();
let def = registry.get("reaction.create").unwrap();
assert_eq!(def.instructions.as_deref(), Some("be concise"));
assert_eq!(def.status(), TypeStatus::Deprecated);
assert_eq!(def.rate_hint, Some(5));
assert_eq!(def.max_payload_hint, Some(2048));
assert_eq!(def.roles.as_deref(), Some([Role::Moderator].as_slice()));
let no_roles_override = TypeDeclaration::Import(PackImport {
use_pack: Some(pack_id::REACTIONS.to_owned()),
overrides: BTreeMap::from([(
"reaction.create".to_owned(),
TypeOverride {
instructions: Some("no role override".to_owned()),
..TypeOverride::default()
},
)]),
..PackImport::default()
});
let registry = TypeRegistry::from_declarations(&[no_roles_override], &packs).unwrap();
let def = registry.get("reaction.create").unwrap();
assert_eq!(def.instructions.as_deref(), Some("no role override"));
assert!(def.roles.is_none());
let mut invalid_def = finding_def();
invalid_def.title = String::new();
let invalid_pack = BTreeMap::from([(
"adp:invalid/1.0".to_owned(),
Pack {
id: "adp:invalid/1.0".to_owned(),
title: "Invalid pack".to_owned(),
description: None,
types: vec![invalid_def],
extra: BTreeMap::new(),
},
)]);
let invalid_import = TypeDeclaration::Import(PackImport {
use_pack: Some("adp:invalid/1.0".to_owned()),
..PackImport::default()
});
assert!(TypeRegistry::from_declarations(&[invalid_import], &invalid_pack).is_err());
}
#[test]
fn registry_introspection_and_registry_validation() {
let mut registry = TypeRegistry::new();
assert!(registry.is_empty());
registry.define(finding_def()).unwrap();
assert!(!registry.is_empty());
assert_eq!(registry.definitions().count(), 1);
validate_event_against_registry(
event_type::MESSAGE_CREATE,
&json!({"anything": true}),
®istry,
)
.unwrap();
validate_event_against_registry(
"review.finding",
&json!({"severity": "high", "summary": "s"}),
®istry,
)
.unwrap();
assert!(validate_event_against_registry("review.finding", &json!({}), ®istry).is_err());
let mut invalid_schema = finding_def();
invalid_schema.schema = json!({"type": 123});
registry
.types
.insert(invalid_schema.name.clone(), invalid_schema);
assert!(registry
.validate_payload(
"review.finding",
&json!({"severity": "high", "summary": "s"})
)
.is_err());
}
#[test]
fn custom_permission_edges_and_room_write() {
let mut registry = TypeRegistry::new();
registry.define(finding_def()).unwrap();
let mut disabled = finding_def();
disabled.name = "review.disabled".to_owned();
disabled.status = Some(TypeStatus::Disabled);
registry.define(disabled).unwrap();
assert!(!can_submit_event(
"review.disabled",
&PermissionContext::creator(None),
®istry
));
assert!(!can_submit_event(
"review.finding",
&PermissionContext::default(),
®istry
));
validate_room_write(
event_type::MESSAGE_CREATE,
RoomState::Active,
&PermissionContext::for_role(Role::Speaker),
®istry,
)
.unwrap();
assert!(validate_room_write(
event_type::MESSAGE_CREATE,
RoomState::Ended,
&PermissionContext::for_role(Role::Speaker),
®istry,
)
.is_err());
}
#[test]
fn server_record_chain_violations() {
let signer = AgentSigner::from_seed([43; 32]);
let make = |seq: u64, nonce: u64, pre_hash: Option<String>| {
let envelope = signer
.sign_event(discourse_event(
event_type::MESSAGE_CREATE,
signer.agent_id(),
100,
nonce,
"room1",
1,
"room-head-hash",
json!({"content_type": "text/plain", "content": "hi"}),
))
.unwrap();
build_server_record("room1", seq, pre_hash, 100 + seq as i64, envelope).unwrap()
};
let first = make(1, 1, None);
let gap = make(3, 2, Some(first.hash.clone()));
assert!(verify_server_record_chain(&[first.clone(), gap]).is_err());
let wrong_pre = make(2, 3, Some("not-the-previous-hash".to_owned()));
assert!(verify_server_record_chain(&[first.clone(), wrong_pre]).is_err());
let first_with_pre = make(1, 4, Some("unexpected".to_owned()));
assert!(verify_server_record_chain(&[first_with_pre]).is_err());
}
}