1use serde::{Deserialize, Serialize};
15
16use crate::fleet_exact::{
17 ExactFleet, ExactFleetError, FrozenRoute, PermissionCeiling, RequestedReasoning,
18 canonical_member_key, canonical_role_key,
19};
20use crate::named_fleet::{FleetDocument, FleetSchema};
21use crate::reasoning_router::CapturedReasoningRouter;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct QualifiedFleetId {
34 pub name: String,
36 pub origin: String,
38}
39
40impl QualifiedFleetId {
41 #[must_use]
43 pub fn qualified(&self) -> String {
44 format!("{}/{}", self.origin, self.name)
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct FleetSnapshotMember {
51 pub id: String,
52 pub role: String,
53 pub route: FrozenRoute,
55 pub requested_reasoning: RequestedReasoning,
58 pub permissions: PermissionCeiling,
59}
60
61pub type FleetSnapshotRouter = CapturedReasoningRouter;
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct FleetSnapshotLegacyRole {
71 pub role: String,
72 pub profile: String,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct FleetSnapshot {
81 fleet: QualifiedFleetId,
82 schema_kind: String,
83 schema_revision: u32,
84 schema_hash: String,
86 content_hash: String,
89 members: Vec<FleetSnapshotMember>,
90 router: Option<FleetSnapshotRouter>,
94 legacy_roles: Vec<FleetSnapshotLegacyRole>,
95 captured_at: String,
97}
98
99impl FleetSnapshot {
100 pub fn capture(
114 fleet: QualifiedFleetId,
115 document: &FleetDocument,
116 captured_at: impl Into<String>,
117 router: Option<CapturedReasoningRouter>,
118 ) -> Result<Self, ExactFleetError> {
119 let (members, legacy_roles) = match document.schema() {
120 FleetSchema::Exact(exact) => {
121 exact.validate()?;
122 (exact_members(exact), Vec::new())
123 }
124 FleetSchema::Legacy(legacy) => (
125 Vec::new(),
126 legacy
127 .roles
128 .iter()
129 .map(|(role, profile)| FleetSnapshotLegacyRole {
130 role: role.clone(),
131 profile: profile.clone(),
132 })
133 .collect(),
134 ),
135 };
136
137 let mut snapshot = Self {
138 fleet,
139 schema_kind: document.schema_kind().to_string(),
140 schema_revision: document.schema_revision(),
141 schema_hash: document.source_hash().to_string(),
142 content_hash: String::new(),
143 members,
144 router,
145 legacy_roles,
146 captured_at: captured_at.into(),
147 };
148 snapshot.content_hash = snapshot.compute_content_hash();
149 Ok(snapshot)
150 }
151
152 pub fn verify_content_hash(&self) -> Result<(), ExactFleetError> {
167 let recomputed = self.compute_content_hash();
168 if recomputed == self.content_hash {
169 return Ok(());
170 }
171 Err(ExactFleetError::ContentHashMismatch {
172 fleet: self.fleet.qualified(),
173 recorded: self.content_hash.clone(),
174 recomputed,
175 })
176 }
177
178 pub fn into_verified(self) -> Result<Self, ExactFleetError> {
184 self.verify_content_hash()?;
185 Ok(self)
186 }
187
188 fn compute_content_hash(&self) -> String {
189 #[derive(Serialize)]
192 struct Shape<'a> {
193 fleet: &'a QualifiedFleetId,
194 schema_kind: &'a str,
195 schema_revision: u32,
196 schema_hash: &'a str,
197 members: &'a [FleetSnapshotMember],
198 router: &'a Option<FleetSnapshotRouter>,
199 legacy_roles: &'a [FleetSnapshotLegacyRole],
200 }
201
202 let shape = Shape {
203 fleet: &self.fleet,
204 schema_kind: &self.schema_kind,
205 schema_revision: self.schema_revision,
206 schema_hash: &self.schema_hash,
207 members: &self.members,
208 router: &self.router,
209 legacy_roles: &self.legacy_roles,
210 };
211 let encoded = serde_json::to_vec(&shape).expect("snapshot shape is serializable");
212 crate::named_fleet::sha256_label(&encoded)
213 }
214
215 #[must_use]
216 pub fn fleet(&self) -> &QualifiedFleetId {
217 &self.fleet
218 }
219
220 #[must_use]
221 pub fn schema_kind(&self) -> &str {
222 &self.schema_kind
223 }
224
225 #[must_use]
226 pub const fn schema_revision(&self) -> u32 {
227 self.schema_revision
228 }
229
230 #[must_use]
231 pub fn schema_hash(&self) -> &str {
232 &self.schema_hash
233 }
234
235 #[must_use]
236 pub fn content_hash(&self) -> &str {
237 &self.content_hash
238 }
239
240 #[must_use]
241 pub fn members(&self) -> &[FleetSnapshotMember] {
242 &self.members
243 }
244
245 #[must_use]
246 pub fn router(&self) -> Option<&FleetSnapshotRouter> {
247 self.router.as_ref()
248 }
249
250 #[must_use]
251 pub fn legacy_roles(&self) -> &[FleetSnapshotLegacyRole] {
252 &self.legacy_roles
253 }
254
255 #[must_use]
256 pub fn captured_at(&self) -> &str {
257 &self.captured_at
258 }
259
260 #[must_use]
262 pub fn member(&self, id: &str) -> Option<&FleetSnapshotMember> {
263 let key = canonical_member_key(id);
264 self.members
265 .iter()
266 .find(|member| canonical_member_key(&member.id) == key)
267 }
268
269 #[must_use]
277 pub fn member_by_role(&self, role: &str) -> Option<&FleetSnapshotMember> {
278 let key = canonical_role_key(role);
279 self.members
280 .iter()
281 .find(|member| canonical_role_key(&member.role) == key)
282 }
283
284 #[must_use]
287 pub fn member_by_id_or_role(&self, id_or_role: &str) -> Option<&FleetSnapshotMember> {
288 self.member(id_or_role)
289 .or_else(|| self.member_by_role(id_or_role))
290 }
291
292 #[must_use]
295 pub fn has_auto_member(&self) -> bool {
296 self.members
297 .iter()
298 .any(|member| member.requested_reasoning.is_auto())
299 }
300
301 #[must_use]
304 pub fn auto_member_ids(&self) -> Vec<String> {
305 self.members
306 .iter()
307 .filter(|member| member.requested_reasoning.is_auto())
308 .map(|member| member.id.clone())
309 .collect()
310 }
311}
312
313fn exact_members(exact: &ExactFleet) -> Vec<FleetSnapshotMember> {
314 exact
315 .members
316 .iter()
317 .map(|member| FleetSnapshotMember {
318 id: canonical_member_key(&member.id),
319 role: canonical_role_key(&member.role),
324 route: member.frozen_route(),
325 requested_reasoning: member.reasoning,
326 permissions: member.permissions,
327 })
328 .collect()
329}
330
331pub fn verify_snapshot_content_hash(snapshot: &FleetSnapshot) -> Result<(), ExactFleetError> {
340 snapshot.verify_content_hash()
341}
342
343#[must_use]
351pub fn captured_legacy_inline_router(exact: &ExactFleet) -> Option<CapturedReasoningRouter> {
352 exact
353 .legacy_inline_router()
354 .map(CapturedReasoningRouter::from_legacy_inline)
355}
356
357#[cfg(test)]
358mod content_hash_tests {
359 use super::*;
360
361 const EXACT_FLEET: &str = r#"
362name = "glm-pair"
363schema = "exact"
364
365[[members]]
366id = "implementer"
367role = "builder"
368provider = "zai"
369model = "glm-5"
370reasoning = "high"
371permissions = "read_write"
372
373[[members]]
374id = "advisor-one"
375role = "oracle"
376provider = "zai"
377model = "glm-5"
378reasoning = "low"
379permissions = "analyst"
380"#;
381
382 fn captured() -> FleetSnapshot {
383 let document = FleetDocument::parse(EXACT_FLEET).expect("parse fleet document");
384 FleetSnapshot::capture(
385 QualifiedFleetId {
386 name: "glm-pair".to_string(),
387 origin: "workspace".to_string(),
388 },
389 &document,
390 "2026-07-26T00:00:00Z",
391 None,
392 )
393 .expect("capture")
394 }
395
396 #[test]
397 fn a_freshly_captured_snapshot_verifies() {
398 let snapshot = captured();
399 assert!(snapshot.verify_content_hash().is_ok());
400 assert!(verify_snapshot_content_hash(&snapshot).is_ok());
401 assert!(snapshot.into_verified().is_ok());
402 }
403
404 #[test]
408 fn an_untouched_round_trip_still_verifies() {
409 let snapshot = captured();
410 let encoded = serde_json::to_string(&snapshot).expect("serialize");
411 let decoded: FleetSnapshot = serde_json::from_str(&encoded).expect("deserialize");
412
413 assert_eq!(decoded, snapshot);
414 assert!(decoded.verify_content_hash().is_ok());
415 }
416
417 #[test]
421 fn an_edited_member_is_rejected_while_the_hash_still_claims_the_original() {
422 let snapshot = captured();
423 let original_hash = snapshot.content_hash().to_string();
424
425 let mut value = serde_json::to_value(&snapshot).expect("serialize");
426 value["members"][0]["route"]["model"] = serde_json::json!("glm-5-max");
429 let tampered: FleetSnapshot = serde_json::from_value(value).expect("deserialize");
430
431 assert_eq!(
432 tampered.content_hash(),
433 original_hash,
434 "the tamper does not touch the recorded hash — that is the point"
435 );
436 let error = tampered
437 .verify_content_hash()
438 .expect_err("a tampered snapshot must not verify");
439 assert!(matches!(
440 error,
441 ExactFleetError::ContentHashMismatch { ref recorded, .. } if *recorded == original_hash
442 ));
443 assert!(tampered.into_verified().is_err());
444 }
445
446 #[test]
449 fn a_widened_permission_ceiling_is_rejected() {
450 let snapshot = captured();
451 let mut value = serde_json::to_value(&snapshot).expect("serialize");
452 value["members"][1]["permissions"]["write"] = serde_json::json!(true);
453 let tampered: FleetSnapshot = serde_json::from_value(value).expect("deserialize");
454
455 assert!(tampered.verify_content_hash().is_err());
456 }
457
458 #[test]
461 fn a_forged_hash_is_rejected() {
462 let snapshot = captured();
463 let mut value = serde_json::to_value(&snapshot).expect("serialize");
464 value["content_hash"] = serde_json::json!("0".repeat(64));
465 let forged: FleetSnapshot = serde_json::from_value(value).expect("deserialize");
466
467 assert!(forged.verify_content_hash().is_err());
468 }
469
470 #[test]
476 fn a_pre_rename_snapshot_is_rejected_rather_than_silently_relabelled() {
477 let snapshot = captured();
478 assert_eq!(
479 snapshot.members()[1].role,
480 "consultant",
481 "capture records the canonical role"
482 );
483
484 let mut value = serde_json::to_value(&snapshot).expect("serialize");
485 value["members"][1]["role"] = serde_json::json!("oracle");
486 let migrated: FleetSnapshot = serde_json::from_value(value).expect("deserialize");
487
488 assert!(migrated.verify_content_hash().is_err());
489 assert!(migrated.member_by_role("consultant").is_some());
492 }
493
494 #[test]
497 fn snapshot_role_lookup_accepts_both_spellings() {
498 let snapshot = captured();
499
500 for spelling in ["consultant", "oracle", "advisor", "ORACLE"] {
501 assert_eq!(
502 snapshot
503 .member_by_role(spelling)
504 .unwrap_or_else(|| panic!("`{spelling}` must resolve"))
505 .id,
506 "advisor-one"
507 );
508 }
509 assert!(snapshot.member_by_id_or_role("oracle").is_some());
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use crate::fleet_exact::ShellCeiling;
517 use crate::reasoning_router::{
518 LEGACY_INLINE_ROUTER_ORIGIN, REASONING_ROUTER_SERVICE_KIND, ReasoningRouterProfile,
519 RouterCallReasoning,
520 };
521
522 const EXACT: &str = r#"
525name = "glm-pair"
526schema = "exact"
527reasoning_router = "luna-low"
528
529[[members]]
530id = "implementer"
531role = "builder"
532provider = "zai"
533model = "glm-5"
534reasoning = "auto"
535permissions = "read_write"
536
537[[members]]
538id = "auditor"
539provider = "zai"
540model = "glm-5"
541reasoning = "high"
542permissions = "read_only"
543"#;
544
545 const LEGACY_INLINE: &str = r#"
547name = "glm-pair"
548schema = "exact"
549
550[[members]]
551id = "implementer"
552role = "builder"
553provider = "zai"
554model = "glm-5"
555reasoning = "auto"
556permissions = "read_write"
557
558[[members]]
559id = "router"
560kind = "router"
561provider = "zai"
562model = "glm-5-turbo"
563"#;
564
565 const LEGACY_ROLE_MAP: &str = r#"
566name = "stopship"
567description = "legacy roster"
568
569[roles]
570scout = "scout"
571implementer = "builder"
572"#;
573
574 const LUNA: &str = r#"
575name = "luna-low"
576schema = "reasoning_router"
577provider = "openai"
578model = "gpt-5.6-luna"
579call_reasoning = "low"
580"#;
581
582 fn id() -> QualifiedFleetId {
583 QualifiedFleetId {
584 name: "glm-pair".to_string(),
585 origin: "workspace".to_string(),
586 }
587 }
588
589 fn luna() -> CapturedReasoningRouter {
590 let profile = ReasoningRouterProfile::parse(LUNA).expect("router profile");
591 CapturedReasoningRouter::from_profile(&profile, "workspace")
592 }
593
594 fn capture(text: &str, router: Option<CapturedReasoningRouter>) -> FleetSnapshot {
595 let document = FleetDocument::parse(text).expect("parse");
596 FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", router).expect("capture")
597 }
598
599 #[test]
600 fn snapshot_captures_identity_schema_routes_and_ceilings() {
601 let snapshot = capture(EXACT, Some(luna()));
602
603 assert_eq!(snapshot.fleet().qualified(), "workspace/glm-pair");
604 assert_eq!(snapshot.schema_kind(), "exact");
605 assert_eq!(snapshot.schema_revision(), 1);
606 assert!(snapshot.schema_hash().starts_with("sha256:"));
607 assert!(snapshot.content_hash().starts_with("sha256:"));
608
609 let by_role = snapshot.member_by_role("builder").expect("role lookup");
611 let by_id = snapshot.member("implementer").expect("id lookup");
612 assert_eq!(by_role.id, by_id.id);
613 assert_eq!(by_id.route.provider, "zai");
614 assert_eq!(by_id.route.model, "glm-5");
615 assert_eq!(by_id.requested_reasoning, RequestedReasoning::Auto);
616 assert!(by_id.permissions.write);
617
618 assert!(snapshot.member("builder").is_none());
621 assert!(snapshot.member_by_role("implementer").is_none());
622
623 assert!(snapshot.has_auto_member());
624 assert_eq!(snapshot.auto_member_ids(), vec!["implementer".to_string()]);
625 }
626
627 #[test]
630 fn the_attached_router_is_a_service_and_not_a_roster_member() {
631 let snapshot = capture(EXACT, Some(luna()));
632 let router = snapshot.router().expect("router service");
633
634 assert_eq!(router.service_kind, REASONING_ROUTER_SERVICE_KIND);
635 assert_eq!(router.qualified(), "workspace/luna-low");
636 assert!(!router.legacy_inline);
637 assert!(!router.is_dispatchable());
638 assert!(!router.dispatchable);
639 assert!(router.tool_surface().is_empty());
640 assert_eq!(router.route.provider, "openai");
641 assert_eq!(router.route.model, "gpt-5.6-luna");
642 assert_eq!(router.requested_call_reasoning, RouterCallReasoning::Low);
643 assert_eq!(router.permissions.shell, ShellCeiling::None);
644 assert!(!router.permissions.tools);
645 assert_eq!(router.permissions.delegation_depth, 0);
646
647 assert!(snapshot.member("luna-low").is_none());
649 assert!(snapshot.member_by_role("luna-low").is_none());
650 assert!(snapshot.member_by_id_or_role("router").is_none());
651 }
652
653 #[test]
656 fn one_router_profile_serves_two_fleets() {
657 let first = capture(EXACT, Some(luna()));
658 let second_text = EXACT.replace("name = \"glm-pair\"", "name = \"other-pair\"");
659 let document = FleetDocument::parse(&second_text).expect("parse");
660 let second = FleetSnapshot::capture(
661 QualifiedFleetId {
662 name: "other-pair".to_string(),
663 origin: "workspace".to_string(),
664 },
665 &document,
666 "2026-07-26T00:00:00Z",
667 Some(luna()),
668 )
669 .expect("capture");
670
671 assert_eq!(first.router(), second.router());
672 assert_ne!(first.fleet(), second.fleet());
673 assert_ne!(
674 first.content_hash(),
675 second.content_hash(),
676 "different fleets are still different snapshots"
677 );
678 }
679
680 #[test]
683 fn a_legacy_inline_router_normalizes_into_the_same_captured_service() {
684 let document = FleetDocument::parse(LEGACY_INLINE).expect("parse");
685 let exact = document.exact().expect("exact");
686 let captured = captured_legacy_inline_router(exact).expect("inline router");
687
688 assert!(captured.legacy_inline);
689 assert_eq!(captured.origin, LEGACY_INLINE_ROUTER_ORIGIN);
690 assert_eq!(captured.service_kind, REASONING_ROUTER_SERVICE_KIND);
691 assert_eq!(captured.route.model, "glm-5-turbo");
692 assert_eq!(captured.requested_call_reasoning, RouterCallReasoning::Off);
693 assert!(!captured.is_dispatchable());
694 assert!(!captured.permissions.tools);
695
696 let snapshot = FleetSnapshot::capture(
697 id(),
698 &document,
699 "2026-07-26T00:00:00Z",
700 Some(captured.clone()),
701 )
702 .expect("capture");
703 assert_eq!(snapshot.router(), Some(&captured));
704 assert!(snapshot.member("router").is_none());
706 assert_eq!(snapshot.members().len(), 1);
707 }
708
709 #[test]
710 fn editing_the_saved_fleet_does_not_touch_a_running_snapshot() {
711 let snapshot = capture(EXACT, Some(luna()));
712
713 let edited = EXACT
716 .replace(
717 "model = \"glm-5\"\nreasoning = \"auto\"",
718 "model = \"glm-4\"\nreasoning = \"off\"",
719 )
720 .replace("permissions = \"read_write\"", "permissions = \"full\"");
721 let next = capture(&edited, Some(luna()));
722
723 let member = snapshot.member("implementer").expect("member");
725 assert_eq!(member.route.model, "glm-5");
726 assert_eq!(member.requested_reasoning, RequestedReasoning::Auto);
727 assert!(!member.permissions.network_tool);
728
729 assert_eq!(next.member("implementer").unwrap().route.model, "glm-4");
731 assert_ne!(snapshot.schema_hash(), next.schema_hash());
732 assert_ne!(snapshot.content_hash(), next.content_hash());
733 }
734
735 #[test]
736 fn identical_definitions_produce_an_identical_content_hash() {
737 let document = FleetDocument::parse(EXACT).expect("parse");
738 let a = FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", Some(luna()))
739 .expect("capture");
740 let b = FleetSnapshot::capture(id(), &document, "2026-07-27T09:30:00Z", Some(luna()))
742 .expect("capture");
743
744 assert_eq!(a.content_hash(), b.content_hash());
745 assert_ne!(a.captured_at(), b.captured_at());
746 }
747
748 #[test]
751 fn changing_the_attached_router_changes_the_content_hash() {
752 let with_luna = capture(EXACT, Some(luna()));
753 let without = capture(EXACT, None);
754 assert_ne!(with_luna.content_hash(), without.content_hash());
755 }
756
757 #[test]
758 fn legacy_role_map_fleets_snapshot_as_legacy() {
759 let document = FleetDocument::parse(LEGACY_ROLE_MAP).expect("parse legacy");
760 let snapshot = FleetSnapshot::capture(
761 QualifiedFleetId {
762 name: "stopship".to_string(),
763 origin: "workspace".to_string(),
764 },
765 &document,
766 "2026-07-26T00:00:00Z",
767 None,
768 )
769 .expect("capture");
770
771 assert_eq!(snapshot.schema_kind(), "legacy");
772 assert_eq!(snapshot.schema_revision(), 0);
773 assert!(snapshot.members().is_empty());
774 assert!(snapshot.router().is_none());
775 assert!(!snapshot.has_auto_member());
776 assert_eq!(snapshot.legacy_roles().len(), 2);
777 assert!(
778 snapshot
779 .legacy_roles()
780 .iter()
781 .any(|role| role.role == "implementer" && role.profile == "builder")
782 );
783 }
784
785 #[test]
786 fn snapshot_serialization_carries_no_secret_shaped_fields() {
787 let snapshot = capture(EXACT, Some(luna()));
788 let json = serde_json::to_string(&snapshot).expect("serialize");
789 let lowered = json.to_ascii_lowercase();
790
791 for forbidden in [
792 "api_key",
793 "apikey",
794 "secret",
795 "token",
796 "bearer",
797 "password",
798 "base_url",
799 "credential",
800 "authorization",
801 ] {
802 assert!(
803 !lowered.contains(forbidden),
804 "snapshot must not carry `{forbidden}`: {json}"
805 );
806 }
807
808 let back: FleetSnapshot = serde_json::from_str(&json).expect("deserialize");
810 assert_eq!(back, snapshot);
811 }
812
813 #[test]
817 fn a_snapshot_carries_no_filesystem_path() {
818 let tmp = tempfile::tempdir().expect("tmp");
819 std::fs::create_dir_all(tmp.path().join("fleets")).expect("dirs");
820 let path = tmp.path().join("fleets/glm-pair.toml");
821 std::fs::write(&path, EXACT).expect("write");
822 let document = FleetDocument::load(&path, Some("glm-pair")).expect("load from disk");
823 assert!(document.source_path().is_some());
825
826 let snapshot =
827 FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", Some(luna()))
828 .expect("capture");
829 let json = serde_json::to_string(&snapshot).expect("serialize");
830
831 assert!(!json.contains(&tmp.path().display().to_string()), "{json}");
832 for fragment in ["/Users/", "/home/", "/private/", ".toml", "\\Users\\"] {
833 assert!(
834 !json.contains(fragment),
835 "snapshot must not carry `{fragment}`: {json}"
836 );
837 }
838 assert_eq!(snapshot.fleet().qualified(), "workspace/glm-pair");
839 assert!(snapshot.content_hash().starts_with("sha256:"));
840 }
841
842 #[test]
845 fn capture_revalidates_a_roster_that_bypassed_the_parser() {
846 use crate::fleet_exact::ExactMember;
847
848 let member = |id: &str, role: &str| ExactMember {
849 id: id.to_string(),
850 role: role.to_string(),
851 provider: "zai".to_string(),
852 model: "glm-5".to_string(),
853 reasoning: RequestedReasoning::Off,
854 permissions: PermissionCeiling::default(),
855 };
856 let smuggled = ExactFleet {
857 name: "f".to_string(),
858 description: None,
859 schema_revision: 1,
860 reasoning_router: None,
861 members: vec![member("a", "builder"), member("b", "builder")],
863 router: None,
864 };
865
866 let document = FleetDocument::from_exact_for_tests(smuggled);
867 let err = FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", None)
868 .expect_err("capture must revalidate");
869 assert!(
870 matches!(err, ExactFleetError::DuplicateRole { .. }),
871 "{err:?}"
872 );
873 }
874}