use serde::{Deserialize, Serialize};
use crate::fleet_exact::{
ExactFleet, ExactFleetError, FrozenRoute, PermissionCeiling, RequestedReasoning,
canonical_member_key, canonical_role_key,
};
use crate::named_fleet::{FleetDocument, FleetSchema};
use crate::reasoning_router::CapturedReasoningRouter;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QualifiedFleetId {
pub name: String,
pub origin: String,
}
impl QualifiedFleetId {
#[must_use]
pub fn qualified(&self) -> String {
format!("{}/{}", self.origin, self.name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FleetSnapshotMember {
pub id: String,
pub role: String,
pub route: FrozenRoute,
pub requested_reasoning: RequestedReasoning,
pub permissions: PermissionCeiling,
}
pub type FleetSnapshotRouter = CapturedReasoningRouter;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FleetSnapshotLegacyRole {
pub role: String,
pub profile: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FleetSnapshot {
fleet: QualifiedFleetId,
schema_kind: String,
schema_revision: u32,
schema_hash: String,
content_hash: String,
members: Vec<FleetSnapshotMember>,
router: Option<FleetSnapshotRouter>,
legacy_roles: Vec<FleetSnapshotLegacyRole>,
captured_at: String,
}
impl FleetSnapshot {
pub fn capture(
fleet: QualifiedFleetId,
document: &FleetDocument,
captured_at: impl Into<String>,
router: Option<CapturedReasoningRouter>,
) -> Result<Self, ExactFleetError> {
let (members, legacy_roles) = match document.schema() {
FleetSchema::Exact(exact) => {
exact.validate()?;
(exact_members(exact), Vec::new())
}
FleetSchema::Legacy(legacy) => (
Vec::new(),
legacy
.roles
.iter()
.map(|(role, profile)| FleetSnapshotLegacyRole {
role: role.clone(),
profile: profile.clone(),
})
.collect(),
),
};
let mut snapshot = Self {
fleet,
schema_kind: document.schema_kind().to_string(),
schema_revision: document.schema_revision(),
schema_hash: document.source_hash().to_string(),
content_hash: String::new(),
members,
router,
legacy_roles,
captured_at: captured_at.into(),
};
snapshot.content_hash = snapshot.compute_content_hash();
Ok(snapshot)
}
pub fn verify_content_hash(&self) -> Result<(), ExactFleetError> {
let recomputed = self.compute_content_hash();
if recomputed == self.content_hash {
return Ok(());
}
Err(ExactFleetError::ContentHashMismatch {
fleet: self.fleet.qualified(),
recorded: self.content_hash.clone(),
recomputed,
})
}
pub fn into_verified(self) -> Result<Self, ExactFleetError> {
self.verify_content_hash()?;
Ok(self)
}
fn compute_content_hash(&self) -> String {
#[derive(Serialize)]
struct Shape<'a> {
fleet: &'a QualifiedFleetId,
schema_kind: &'a str,
schema_revision: u32,
schema_hash: &'a str,
members: &'a [FleetSnapshotMember],
router: &'a Option<FleetSnapshotRouter>,
legacy_roles: &'a [FleetSnapshotLegacyRole],
}
let shape = Shape {
fleet: &self.fleet,
schema_kind: &self.schema_kind,
schema_revision: self.schema_revision,
schema_hash: &self.schema_hash,
members: &self.members,
router: &self.router,
legacy_roles: &self.legacy_roles,
};
let encoded = serde_json::to_vec(&shape).expect("snapshot shape is serializable");
crate::named_fleet::sha256_label(&encoded)
}
#[must_use]
pub fn fleet(&self) -> &QualifiedFleetId {
&self.fleet
}
#[must_use]
pub fn schema_kind(&self) -> &str {
&self.schema_kind
}
#[must_use]
pub const fn schema_revision(&self) -> u32 {
self.schema_revision
}
#[must_use]
pub fn schema_hash(&self) -> &str {
&self.schema_hash
}
#[must_use]
pub fn content_hash(&self) -> &str {
&self.content_hash
}
#[must_use]
pub fn members(&self) -> &[FleetSnapshotMember] {
&self.members
}
#[must_use]
pub fn router(&self) -> Option<&FleetSnapshotRouter> {
self.router.as_ref()
}
#[must_use]
pub fn legacy_roles(&self) -> &[FleetSnapshotLegacyRole] {
&self.legacy_roles
}
#[must_use]
pub fn captured_at(&self) -> &str {
&self.captured_at
}
#[must_use]
pub fn member(&self, id: &str) -> Option<&FleetSnapshotMember> {
let key = canonical_member_key(id);
self.members
.iter()
.find(|member| canonical_member_key(&member.id) == key)
}
#[must_use]
pub fn member_by_role(&self, role: &str) -> Option<&FleetSnapshotMember> {
let key = canonical_role_key(role);
self.members
.iter()
.find(|member| canonical_role_key(&member.role) == key)
}
#[must_use]
pub fn member_by_id_or_role(&self, id_or_role: &str) -> Option<&FleetSnapshotMember> {
self.member(id_or_role)
.or_else(|| self.member_by_role(id_or_role))
}
#[must_use]
pub fn has_auto_member(&self) -> bool {
self.members
.iter()
.any(|member| member.requested_reasoning.is_auto())
}
#[must_use]
pub fn auto_member_ids(&self) -> Vec<String> {
self.members
.iter()
.filter(|member| member.requested_reasoning.is_auto())
.map(|member| member.id.clone())
.collect()
}
}
fn exact_members(exact: &ExactFleet) -> Vec<FleetSnapshotMember> {
exact
.members
.iter()
.map(|member| FleetSnapshotMember {
id: canonical_member_key(&member.id),
role: canonical_role_key(&member.role),
route: member.frozen_route(),
requested_reasoning: member.reasoning,
permissions: member.permissions,
})
.collect()
}
pub fn verify_snapshot_content_hash(snapshot: &FleetSnapshot) -> Result<(), ExactFleetError> {
snapshot.verify_content_hash()
}
#[must_use]
pub fn captured_legacy_inline_router(exact: &ExactFleet) -> Option<CapturedReasoningRouter> {
exact
.legacy_inline_router()
.map(CapturedReasoningRouter::from_legacy_inline)
}
#[cfg(test)]
mod content_hash_tests {
use super::*;
const EXACT_FLEET: &str = r#"
name = "glm-pair"
schema = "exact"
[[members]]
id = "implementer"
role = "builder"
provider = "zai"
model = "glm-5"
reasoning = "high"
permissions = "read_write"
[[members]]
id = "advisor-one"
role = "oracle"
provider = "zai"
model = "glm-5"
reasoning = "low"
permissions = "analyst"
"#;
fn captured() -> FleetSnapshot {
let document = FleetDocument::parse(EXACT_FLEET).expect("parse fleet document");
FleetSnapshot::capture(
QualifiedFleetId {
name: "glm-pair".to_string(),
origin: "workspace".to_string(),
},
&document,
"2026-07-26T00:00:00Z",
None,
)
.expect("capture")
}
#[test]
fn a_freshly_captured_snapshot_verifies() {
let snapshot = captured();
assert!(snapshot.verify_content_hash().is_ok());
assert!(verify_snapshot_content_hash(&snapshot).is_ok());
assert!(snapshot.into_verified().is_ok());
}
#[test]
fn an_untouched_round_trip_still_verifies() {
let snapshot = captured();
let encoded = serde_json::to_string(&snapshot).expect("serialize");
let decoded: FleetSnapshot = serde_json::from_str(&encoded).expect("deserialize");
assert_eq!(decoded, snapshot);
assert!(decoded.verify_content_hash().is_ok());
}
#[test]
fn an_edited_member_is_rejected_while_the_hash_still_claims_the_original() {
let snapshot = captured();
let original_hash = snapshot.content_hash().to_string();
let mut value = serde_json::to_value(&snapshot).expect("serialize");
value["members"][0]["route"]["model"] = serde_json::json!("glm-5-max");
let tampered: FleetSnapshot = serde_json::from_value(value).expect("deserialize");
assert_eq!(
tampered.content_hash(),
original_hash,
"the tamper does not touch the recorded hash — that is the point"
);
let error = tampered
.verify_content_hash()
.expect_err("a tampered snapshot must not verify");
assert!(matches!(
error,
ExactFleetError::ContentHashMismatch { ref recorded, .. } if *recorded == original_hash
));
assert!(tampered.into_verified().is_err());
}
#[test]
fn a_widened_permission_ceiling_is_rejected() {
let snapshot = captured();
let mut value = serde_json::to_value(&snapshot).expect("serialize");
value["members"][1]["permissions"]["write"] = serde_json::json!(true);
let tampered: FleetSnapshot = serde_json::from_value(value).expect("deserialize");
assert!(tampered.verify_content_hash().is_err());
}
#[test]
fn a_forged_hash_is_rejected() {
let snapshot = captured();
let mut value = serde_json::to_value(&snapshot).expect("serialize");
value["content_hash"] = serde_json::json!("0".repeat(64));
let forged: FleetSnapshot = serde_json::from_value(value).expect("deserialize");
assert!(forged.verify_content_hash().is_err());
}
#[test]
fn a_pre_rename_snapshot_is_rejected_rather_than_silently_relabelled() {
let snapshot = captured();
assert_eq!(
snapshot.members()[1].role,
"consultant",
"capture records the canonical role"
);
let mut value = serde_json::to_value(&snapshot).expect("serialize");
value["members"][1]["role"] = serde_json::json!("oracle");
let migrated: FleetSnapshot = serde_json::from_value(value).expect("deserialize");
assert!(migrated.verify_content_hash().is_err());
assert!(migrated.member_by_role("consultant").is_some());
}
#[test]
fn snapshot_role_lookup_accepts_both_spellings() {
let snapshot = captured();
for spelling in ["consultant", "oracle", "advisor", "ORACLE"] {
assert_eq!(
snapshot
.member_by_role(spelling)
.unwrap_or_else(|| panic!("`{spelling}` must resolve"))
.id,
"advisor-one"
);
}
assert!(snapshot.member_by_id_or_role("oracle").is_some());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fleet_exact::ShellCeiling;
use crate::reasoning_router::{
LEGACY_INLINE_ROUTER_ORIGIN, REASONING_ROUTER_SERVICE_KIND, ReasoningRouterProfile,
RouterCallReasoning,
};
const EXACT: &str = r#"
name = "glm-pair"
schema = "exact"
reasoning_router = "luna-low"
[[members]]
id = "implementer"
role = "builder"
provider = "zai"
model = "glm-5"
reasoning = "auto"
permissions = "read_write"
[[members]]
id = "auditor"
provider = "zai"
model = "glm-5"
reasoning = "high"
permissions = "read_only"
"#;
const LEGACY_INLINE: &str = r#"
name = "glm-pair"
schema = "exact"
[[members]]
id = "implementer"
role = "builder"
provider = "zai"
model = "glm-5"
reasoning = "auto"
permissions = "read_write"
[[members]]
id = "router"
kind = "router"
provider = "zai"
model = "glm-5-turbo"
"#;
const LEGACY_ROLE_MAP: &str = r#"
name = "stopship"
description = "legacy roster"
[roles]
scout = "scout"
implementer = "builder"
"#;
const LUNA: &str = r#"
name = "luna-low"
schema = "reasoning_router"
provider = "openai"
model = "gpt-5.6-luna"
call_reasoning = "low"
"#;
fn id() -> QualifiedFleetId {
QualifiedFleetId {
name: "glm-pair".to_string(),
origin: "workspace".to_string(),
}
}
fn luna() -> CapturedReasoningRouter {
let profile = ReasoningRouterProfile::parse(LUNA).expect("router profile");
CapturedReasoningRouter::from_profile(&profile, "workspace")
}
fn capture(text: &str, router: Option<CapturedReasoningRouter>) -> FleetSnapshot {
let document = FleetDocument::parse(text).expect("parse");
FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", router).expect("capture")
}
#[test]
fn snapshot_captures_identity_schema_routes_and_ceilings() {
let snapshot = capture(EXACT, Some(luna()));
assert_eq!(snapshot.fleet().qualified(), "workspace/glm-pair");
assert_eq!(snapshot.schema_kind(), "exact");
assert_eq!(snapshot.schema_revision(), 1);
assert!(snapshot.schema_hash().starts_with("sha256:"));
assert!(snapshot.content_hash().starts_with("sha256:"));
let by_role = snapshot.member_by_role("builder").expect("role lookup");
let by_id = snapshot.member("implementer").expect("id lookup");
assert_eq!(by_role.id, by_id.id);
assert_eq!(by_id.route.provider, "zai");
assert_eq!(by_id.route.model, "glm-5");
assert_eq!(by_id.requested_reasoning, RequestedReasoning::Auto);
assert!(by_id.permissions.write);
assert!(snapshot.member("builder").is_none());
assert!(snapshot.member_by_role("implementer").is_none());
assert!(snapshot.has_auto_member());
assert_eq!(snapshot.auto_member_ids(), vec!["implementer".to_string()]);
}
#[test]
fn the_attached_router_is_a_service_and_not_a_roster_member() {
let snapshot = capture(EXACT, Some(luna()));
let router = snapshot.router().expect("router service");
assert_eq!(router.service_kind, REASONING_ROUTER_SERVICE_KIND);
assert_eq!(router.qualified(), "workspace/luna-low");
assert!(!router.legacy_inline);
assert!(!router.is_dispatchable());
assert!(!router.dispatchable);
assert!(router.tool_surface().is_empty());
assert_eq!(router.route.provider, "openai");
assert_eq!(router.route.model, "gpt-5.6-luna");
assert_eq!(router.requested_call_reasoning, RouterCallReasoning::Low);
assert_eq!(router.permissions.shell, ShellCeiling::None);
assert!(!router.permissions.tools);
assert_eq!(router.permissions.delegation_depth, 0);
assert!(snapshot.member("luna-low").is_none());
assert!(snapshot.member_by_role("luna-low").is_none());
assert!(snapshot.member_by_id_or_role("router").is_none());
}
#[test]
fn one_router_profile_serves_two_fleets() {
let first = capture(EXACT, Some(luna()));
let second_text = EXACT.replace("name = \"glm-pair\"", "name = \"other-pair\"");
let document = FleetDocument::parse(&second_text).expect("parse");
let second = FleetSnapshot::capture(
QualifiedFleetId {
name: "other-pair".to_string(),
origin: "workspace".to_string(),
},
&document,
"2026-07-26T00:00:00Z",
Some(luna()),
)
.expect("capture");
assert_eq!(first.router(), second.router());
assert_ne!(first.fleet(), second.fleet());
assert_ne!(
first.content_hash(),
second.content_hash(),
"different fleets are still different snapshots"
);
}
#[test]
fn a_legacy_inline_router_normalizes_into_the_same_captured_service() {
let document = FleetDocument::parse(LEGACY_INLINE).expect("parse");
let exact = document.exact().expect("exact");
let captured = captured_legacy_inline_router(exact).expect("inline router");
assert!(captured.legacy_inline);
assert_eq!(captured.origin, LEGACY_INLINE_ROUTER_ORIGIN);
assert_eq!(captured.service_kind, REASONING_ROUTER_SERVICE_KIND);
assert_eq!(captured.route.model, "glm-5-turbo");
assert_eq!(captured.requested_call_reasoning, RouterCallReasoning::Off);
assert!(!captured.is_dispatchable());
assert!(!captured.permissions.tools);
let snapshot = FleetSnapshot::capture(
id(),
&document,
"2026-07-26T00:00:00Z",
Some(captured.clone()),
)
.expect("capture");
assert_eq!(snapshot.router(), Some(&captured));
assert!(snapshot.member("router").is_none());
assert_eq!(snapshot.members().len(), 1);
}
#[test]
fn editing_the_saved_fleet_does_not_touch_a_running_snapshot() {
let snapshot = capture(EXACT, Some(luna()));
let edited = EXACT
.replace(
"model = \"glm-5\"\nreasoning = \"auto\"",
"model = \"glm-4\"\nreasoning = \"off\"",
)
.replace("permissions = \"read_write\"", "permissions = \"full\"");
let next = capture(&edited, Some(luna()));
let member = snapshot.member("implementer").expect("member");
assert_eq!(member.route.model, "glm-5");
assert_eq!(member.requested_reasoning, RequestedReasoning::Auto);
assert!(!member.permissions.network_tool);
assert_eq!(next.member("implementer").unwrap().route.model, "glm-4");
assert_ne!(snapshot.schema_hash(), next.schema_hash());
assert_ne!(snapshot.content_hash(), next.content_hash());
}
#[test]
fn identical_definitions_produce_an_identical_content_hash() {
let document = FleetDocument::parse(EXACT).expect("parse");
let a = FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", Some(luna()))
.expect("capture");
let b = FleetSnapshot::capture(id(), &document, "2026-07-27T09:30:00Z", Some(luna()))
.expect("capture");
assert_eq!(a.content_hash(), b.content_hash());
assert_ne!(a.captured_at(), b.captured_at());
}
#[test]
fn changing_the_attached_router_changes_the_content_hash() {
let with_luna = capture(EXACT, Some(luna()));
let without = capture(EXACT, None);
assert_ne!(with_luna.content_hash(), without.content_hash());
}
#[test]
fn legacy_role_map_fleets_snapshot_as_legacy() {
let document = FleetDocument::parse(LEGACY_ROLE_MAP).expect("parse legacy");
let snapshot = FleetSnapshot::capture(
QualifiedFleetId {
name: "stopship".to_string(),
origin: "workspace".to_string(),
},
&document,
"2026-07-26T00:00:00Z",
None,
)
.expect("capture");
assert_eq!(snapshot.schema_kind(), "legacy");
assert_eq!(snapshot.schema_revision(), 0);
assert!(snapshot.members().is_empty());
assert!(snapshot.router().is_none());
assert!(!snapshot.has_auto_member());
assert_eq!(snapshot.legacy_roles().len(), 2);
assert!(
snapshot
.legacy_roles()
.iter()
.any(|role| role.role == "implementer" && role.profile == "builder")
);
}
#[test]
fn snapshot_serialization_carries_no_secret_shaped_fields() {
let snapshot = capture(EXACT, Some(luna()));
let json = serde_json::to_string(&snapshot).expect("serialize");
let lowered = json.to_ascii_lowercase();
for forbidden in [
"api_key",
"apikey",
"secret",
"token",
"bearer",
"password",
"base_url",
"credential",
"authorization",
] {
assert!(
!lowered.contains(forbidden),
"snapshot must not carry `{forbidden}`: {json}"
);
}
let back: FleetSnapshot = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, snapshot);
}
#[test]
fn a_snapshot_carries_no_filesystem_path() {
let tmp = tempfile::tempdir().expect("tmp");
std::fs::create_dir_all(tmp.path().join("fleets")).expect("dirs");
let path = tmp.path().join("fleets/glm-pair.toml");
std::fs::write(&path, EXACT).expect("write");
let document = FleetDocument::load(&path, Some("glm-pair")).expect("load from disk");
assert!(document.source_path().is_some());
let snapshot =
FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", Some(luna()))
.expect("capture");
let json = serde_json::to_string(&snapshot).expect("serialize");
assert!(!json.contains(&tmp.path().display().to_string()), "{json}");
for fragment in ["/Users/", "/home/", "/private/", ".toml", "\\Users\\"] {
assert!(
!json.contains(fragment),
"snapshot must not carry `{fragment}`: {json}"
);
}
assert_eq!(snapshot.fleet().qualified(), "workspace/glm-pair");
assert!(snapshot.content_hash().starts_with("sha256:"));
}
#[test]
fn capture_revalidates_a_roster_that_bypassed_the_parser() {
use crate::fleet_exact::ExactMember;
let member = |id: &str, role: &str| ExactMember {
id: id.to_string(),
role: role.to_string(),
provider: "zai".to_string(),
model: "glm-5".to_string(),
reasoning: RequestedReasoning::Off,
permissions: PermissionCeiling::default(),
};
let smuggled = ExactFleet {
name: "f".to_string(),
description: None,
schema_revision: 1,
reasoning_router: None,
members: vec![member("a", "builder"), member("b", "builder")],
router: None,
};
let document = FleetDocument::from_exact_for_tests(smuggled);
let err = FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", None)
.expect_err("capture must revalidate");
assert!(
matches!(err, ExactFleetError::DuplicateRole { .. }),
"{err:?}"
);
}
}