use crate::forked_participant::{
ForkedParticipantAttachmentId, ForkedParticipantOperationScope, ForkedParticipantOwnerRoute,
ForkedParticipantProvenance, ForkedParticipantRef, ForkedParticipantRequestId,
ForkedParticipantReusePolicy, MAX_FORKED_PARTICIPANT_TTL,
};
use crate::ids::ProfileName;
use crate::ids::{AgentIdentity, MobId};
use chrono::{DateTime, Utc};
use meerkat_core::SessionId;
use serde::{Deserialize, Deserializer, Serialize};
use std::time::Duration;
use thiserror::Error;
pub const MAX_TEMPORARY_COUNCIL_ID_LEN: usize = 128;
pub const MAX_TEMPORARY_COUNCIL_PARTICIPANTS: usize = 8;
pub const MAX_TEMPORARY_COUNCIL_ROUNDS: u32 = 16;
pub const MAX_TEMPORARY_COUNCIL_EXCHANGES: u32 = 64;
pub const MAX_TEMPORARY_COUNCIL_RESULT_BYTES: usize = 64 * 1024;
pub const MIN_TEMPORARY_COUNCIL_RESULT_BYTES: usize = 256;
pub const MAX_TEMPORARY_COUNCIL_DURATION: Duration = MAX_FORKED_PARTICIPANT_TTL;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TemporaryCouncilDurability {
Durable,
ProcessBound,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TemporaryCouncilStoreDurability {
Durable,
ProcessBound,
}
impl TemporaryCouncilStoreDurability {
#[must_use]
pub const fn satisfies(self, required: TemporaryCouncilDurability) -> bool {
matches!(
(required, self),
(TemporaryCouncilDurability::Durable, Self::Durable)
| (TemporaryCouncilDurability::ProcessBound, _)
)
}
}
pub const TEMPORARY_COUNCIL_FINGERPRINT_VERSION: u32 = 1;
pub const TEMPORARY_COUNCIL_MOB_ID_PREFIX: &str = "council--";
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum TemporaryCouncilIdentityError {
#[error("temporary council id must not be empty")]
Empty,
#[error("temporary council id must not exceed {MAX_TEMPORARY_COUNCIL_ID_LEN} bytes")]
TooLong,
#[error("temporary council id must be supplied in canonical trimmed form")]
NonCanonical,
#[error(
"temporary council id may only contain ASCII alphanumerics, '-', '_', '.', or ':' (found {found:?})"
)]
IllegalCharacter {
found: char,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct TemporaryCouncilId(String);
impl TemporaryCouncilId {
pub fn new(raw: impl AsRef<str>) -> Result<Self, TemporaryCouncilIdentityError> {
let raw = raw.as_ref();
if raw.is_empty() {
return Err(TemporaryCouncilIdentityError::Empty);
}
if raw.trim() != raw {
return Err(TemporaryCouncilIdentityError::NonCanonical);
}
if raw.len() > MAX_TEMPORARY_COUNCIL_ID_LEN {
return Err(TemporaryCouncilIdentityError::TooLong);
}
if let Some(found) = raw
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':')))
{
return Err(TemporaryCouncilIdentityError::IllegalCharacter { found });
}
Ok(Self(raw.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn temporary_mob_id(&self) -> MobId {
MobId::from(format!("{TEMPORARY_COUNCIL_MOB_ID_PREFIX}{}", self.0))
}
pub fn capability_request_id(
&self,
order: u32,
) -> Result<ForkedParticipantRequestId, crate::forked_participant::ForkedParticipantIdentityError>
{
ForkedParticipantRequestId::new(format!("council:{}:p{order}", self.0))
}
pub fn attachment_id(
&self,
order: u32,
) -> Result<
ForkedParticipantAttachmentId,
crate::forked_participant::ForkedParticipantIdentityError,
> {
ForkedParticipantAttachmentId::new(format!("council:{}:p{order}", self.0))
}
#[must_use]
pub fn delivery_idempotency_key(&self, round: u32, order: u32, purpose: &str) -> String {
format!("council:{}:{purpose}:r{round}:p{order}", self.0)
}
}
impl std::fmt::Display for TemporaryCouncilId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for TemporaryCouncilId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Self::new(raw).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TemporaryCouncilAcquisition {
NotAttempted,
Pending,
Acquired,
Ambiguous,
}
impl TemporaryCouncilAcquisition {
#[must_use]
pub const fn may_exist(self) -> bool {
!matches!(self, Self::NotAttempted)
}
#[must_use]
pub const fn is_resolved(self) -> bool {
matches!(self, Self::Acquired | Self::Ambiguous)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemporaryCouncilParticipantCustody {
pub order: u32,
pub role: String,
pub source_mob_id: MobId,
pub source_identity: AgentIdentity,
pub target_identity: AgentIdentity,
pub target_profile: ProfileName,
pub scope: ForkedParticipantOperationScope,
pub capability_request_id: ForkedParticipantRequestId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_correlation_hint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability_ref: Option<ForkedParticipantRef>,
pub attachment_id: ForkedParticipantAttachmentId,
pub acquisition: TemporaryCouncilAcquisition,
pub seated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seated_session_id: Option<SessionId>,
}
impl TemporaryCouncilParticipantCustody {
#[must_use]
pub const fn acquired_but_unattached(&self) -> bool {
self.acquisition.may_exist() && !self.seated
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemporaryCouncilCapabilityProvenance {
pub owner_route: ForkedParticipantOwnerRoute,
pub fork_session_id: SessionId,
pub source_provenance: ForkedParticipantProvenance,
pub scope: ForkedParticipantOperationScope,
pub reuse: ForkedParticipantReusePolicy,
pub expires_at: DateTime<Utc>,
pub correlation_hint: String,
}
impl TemporaryCouncilCapabilityProvenance {
#[must_use]
pub fn from_reference(capability: &ForkedParticipantRef) -> Self {
Self {
owner_route: capability.owner_route().clone(),
fork_session_id: capability.fork_session_id().clone(),
source_provenance: capability.provenance().clone(),
scope: capability.scope(),
reuse: capability.reuse(),
expires_at: capability.expires_at(),
correlation_hint: capability.capability_id().correlation_hint(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemporaryCouncilParticipantProvenance {
pub order: u32,
pub role: String,
pub source_mob_id: MobId,
pub source_identity: AgentIdentity,
pub target_identity: AgentIdentity,
pub scope: ForkedParticipantOperationScope,
pub capability_request_id: ForkedParticipantRequestId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability: Option<TemporaryCouncilCapabilityProvenance>,
pub attachment_id: ForkedParticipantAttachmentId,
pub seated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
#[non_exhaustive]
pub enum TemporaryCouncilExchangeOutcome {
Pending,
Completed {
text: String,
truncated: bool,
session_id: SessionId,
completed_at: DateTime<Utc>,
},
Failed {
detail: String,
failed_at: DateTime<Utc>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemporaryCouncilExchangeReceipt {
pub round: u32,
pub sequence: u32,
pub participant_order: u32,
pub target_identity: AgentIdentity,
pub delivery_idempotency_key: String,
pub delivery_correlation_id: String,
pub started_at: DateTime<Utc>,
pub outcome: TemporaryCouncilExchangeOutcome,
}
impl TemporaryCouncilExchangeReceipt {
#[must_use]
pub fn completed_text(&self) -> Option<&str> {
match &self.outcome {
TemporaryCouncilExchangeOutcome::Completed { text, .. } => Some(text.as_str()),
_ => None,
}
}
#[must_use]
pub const fn truncated(&self) -> bool {
matches!(
self.outcome,
TemporaryCouncilExchangeOutcome::Completed {
truncated: true,
..
}
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemporaryCouncilArtifactClaim {
pub uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub byte_len: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum TemporaryCouncilMergeOutcome {
NoMerge {
confirmed_participants: Vec<AgentIdentity>,
},
BoundedTextSummary {
finalizer: AgentIdentity,
text: String,
truncated: bool,
},
StructuredResult {
finalizer: AgentIdentity,
contract: TemporaryCouncilStructuredContractIdentity,
value: serde_json::Value,
truncated: bool,
},
SelectedTranscript {
participant: AgentIdentity,
exchange_sequences: Vec<u32>,
excerpts: Vec<TemporaryCouncilSelectedExchange>,
truncated: bool,
},
DurableArtifactReference {
participant: AgentIdentity,
claim: TemporaryCouncilArtifactClaim,
},
NotAttempted {
reason: String,
},
Failed {
policy: TemporaryCouncilMergePolicyKind,
detail: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemporaryCouncilSelectedExchange {
pub sequence: u32,
pub round: u32,
pub participant_order: u32,
pub target_identity: AgentIdentity,
pub text: String,
pub truncated: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TemporaryCouncilMergePolicyKind {
BoundedTextSummary,
StructuredResult,
SelectedTranscript,
DurableArtifactReference,
NoMerge,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemporaryCouncilStructuredContractIdentity {
pub schema_id: String,
pub schema_version: u32,
pub schema_digest: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "reason", rename_all = "snake_case")]
#[non_exhaustive]
pub enum TemporaryCouncilExitReason {
Completed,
MaxExchangesReached,
DeadlineExceeded,
ParticipantSeatingFailed {
participant_order: u32,
detail: String,
},
WiringIncomplete {
detail: String,
},
ExchangeFailed {
round: u32,
target_identity: AgentIdentity,
detail: String,
},
CoordinatorInterrupted,
}
impl TemporaryCouncilExitReason {
#[must_use]
pub const fn is_complete(&self) -> bool {
matches!(self, Self::Completed)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemporaryCouncilResult {
pub council_id: TemporaryCouncilId,
pub request_fingerprint: String,
pub temporary_mob_id: MobId,
pub exit_reason: TemporaryCouncilExitReason,
pub rounds_completed: u32,
pub exchanges: Vec<TemporaryCouncilExchangeReceipt>,
pub merge: TemporaryCouncilMergeOutcome,
pub participants: Vec<TemporaryCouncilParticipantProvenance>,
pub truncated_exchange_count: u32,
pub merge_truncated: bool,
pub durability: TemporaryCouncilDurability,
pub concluded_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemporaryCouncilCleanupDebt {
pub subject: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemporaryCouncilCleanupReceipt {
pub attempted_at: DateTime<Utc>,
pub attempts: u32,
pub temporary_mob_destroyed: bool,
pub released_participants: Vec<u32>,
pub revoked_participants: Vec<u32>,
pub debts: Vec<TemporaryCouncilCleanupDebt>,
pub budget_exhausted: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TemporaryCouncilCleanupStatus {
Settled,
Debt,
Pending,
}
impl TemporaryCouncilCleanupReceipt {
#[must_use]
pub fn settled(&self) -> bool {
self.temporary_mob_destroyed && self.debts.is_empty() && !self.budget_exhausted
}
#[must_use]
pub fn status(&self) -> TemporaryCouncilCleanupStatus {
if self.settled() {
TemporaryCouncilCleanupStatus::Settled
} else if self.budget_exhausted {
TemporaryCouncilCleanupStatus::Pending
} else {
TemporaryCouncilCleanupStatus::Debt
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn council_id_rejects_non_canonical_and_illegal_text() {
assert_eq!(
TemporaryCouncilId::new(""),
Err(TemporaryCouncilIdentityError::Empty)
);
assert_eq!(
TemporaryCouncilId::new(" pad "),
Err(TemporaryCouncilIdentityError::NonCanonical)
);
assert_eq!(
TemporaryCouncilId::new("a".repeat(MAX_TEMPORARY_COUNCIL_ID_LEN + 1)),
Err(TemporaryCouncilIdentityError::TooLong)
);
assert_eq!(
TemporaryCouncilId::new("has space"),
Err(TemporaryCouncilIdentityError::IllegalCharacter { found: ' ' })
);
assert_eq!(
TemporaryCouncilId::new("has/slash"),
Err(TemporaryCouncilIdentityError::IllegalCharacter { found: '/' })
);
let ok = TemporaryCouncilId::new("council.A-1_2:x").expect("canonical id");
assert_eq!(ok.as_str(), "council.A-1_2:x");
}
#[test]
fn derived_identities_are_deterministic_and_slot_scoped() {
let id = TemporaryCouncilId::new("demo").expect("id");
assert_eq!(id.temporary_mob_id().as_str(), "council--demo");
assert_eq!(
id.capability_request_id(2).expect("request id").as_str(),
"council:demo:p2"
);
assert_eq!(
id.attachment_id(2).expect("attachment id").as_str(),
"council:demo:p2"
);
assert_ne!(
id.capability_request_id(1).expect("request id"),
id.capability_request_id(2).expect("request id")
);
assert_eq!(
id.delivery_idempotency_key(0, 1, "round"),
"council:demo:round:r0:p1"
);
assert_ne!(
id.delivery_idempotency_key(0, 1, "round"),
id.delivery_idempotency_key(0, 1, "merge")
);
}
#[test]
fn council_id_round_trips_through_validating_deserialization() {
let id = TemporaryCouncilId::new("round-trip").expect("id");
let encoded = serde_json::to_string(&id).expect("encode");
assert_eq!(encoded, "\"round-trip\"");
let decoded: TemporaryCouncilId = serde_json::from_str(&encoded).expect("decode");
assert_eq!(decoded, id);
assert!(
serde_json::from_str::<TemporaryCouncilId>("\" bad \"").is_err(),
"deserialization must validate, not trim"
);
}
#[test]
fn cleanup_receipt_settles_only_without_debt() {
let mut receipt = TemporaryCouncilCleanupReceipt {
attempted_at: Utc::now(),
attempts: 1,
temporary_mob_destroyed: true,
released_participants: vec![0],
revoked_participants: Vec::new(),
debts: Vec::new(),
budget_exhausted: false,
};
assert!(receipt.settled());
assert_eq!(receipt.status(), TemporaryCouncilCleanupStatus::Settled);
receipt.debts.push(TemporaryCouncilCleanupDebt {
subject: "participant:0".to_string(),
detail: "release failed".to_string(),
});
assert!(!receipt.settled());
assert_eq!(receipt.status(), TemporaryCouncilCleanupStatus::Debt);
receipt.debts.clear();
receipt.temporary_mob_destroyed = false;
assert!(!receipt.settled());
receipt.temporary_mob_destroyed = true;
receipt.budget_exhausted = true;
assert!(
!receipt.settled(),
"an exhausted budget is not a settlement"
);
assert_eq!(receipt.status(), TemporaryCouncilCleanupStatus::Pending);
}
}