| 1 | //! Immutable Fleet snapshot taken at Workflow start. |
| 2 | //! |
| 3 | //! A saved Fleet is editable; a *running* Workflow is not. At start we capture |
| 4 | //! a secret-free, durable value containing the qualified Fleet identity, the |
| 5 | //! schema kind/revision/hash, the exact members, the exact routes, the |
| 6 | //! reasoning policies, and the permission ceilings. Editing the saved file |
| 7 | //! afterwards changes only future runs — the snapshot in flight is unaffected, |
| 8 | //! because it owns copies and exposes no mutators. |
| 9 | //! |
| 10 | //! **No-secrets invariant**: every field here is a non-sensitive id, model |
| 11 | //! string, tier label, or boolean. There is deliberately no field that could |
| 12 | //! hold a credential, token, or base URL. |
| 13 | |
| 14 | use serde::{Deserialize, Serialize}; |
| 15 | |
| 16 | use crate::fleet_exact::{ |
| 17 | ExactFleet, ExactFleetError, FrozenRoute, PermissionCeiling, RequestedReasoning, |
| 18 | canonical_member_key, canonical_role_key, |
| 19 | }; |
| 20 | use crate::named_fleet::{FleetDocument, FleetSchema}; |
| 21 | use crate::reasoning_router::CapturedReasoningRouter; |
| 22 | |
| 23 | /// A Fleet identity qualified by where the definition came from. |
| 24 | /// |
| 25 | /// Deliberately **path-free**. An absolute filesystem path in a durable receipt |
| 26 | /// leaks the operator's home directory, username, and machine layout into |
| 27 | /// journals and events that travel further than the machine that wrote them. |
| 28 | /// `origin/name` plus the schema/content hashes identify a definition precisely |
| 29 | /// enough to compare two runs, without any of that. Local diagnostic errors |
| 30 | /// (fleet not found, ambiguous fleet) still name paths — those are read on the |
| 31 | /// machine that produced them and never persisted onto a receipt. |
| 32 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 33 | pub struct QualifiedFleetId { |
| 34 | /// Fleet name as declared in the file. |
| 35 | pub name: String, |
| 36 | /// Non-secret origin label, e.g. `workspace` or `codewhale_home`. |
| 37 | pub origin: String, |
| 38 | } |
| 39 | |
| 40 | impl QualifiedFleetId { |
| 41 | /// `origin/name` — the stable display form. |
| 42 | #[must_use] |
| 43 | pub fn qualified(&self) -> String { |
| 44 | format!("{}/{}", self.origin, self.name) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | /// One member as frozen into the snapshot. |
| 49 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 50 | pub struct FleetSnapshotMember { |
| 51 | pub id: String, |
| 52 | pub role: String, |
| 53 | /// The exact route, frozen before any reasoning resolution. |
| 54 | pub route: FrozenRoute, |
| 55 | /// The reasoning policy the member requested (not the effective tier — |
| 56 | /// that is resolved per run and recorded on the receipt). |
| 57 | pub requested_reasoning: RequestedReasoning, |
| 58 | pub permissions: PermissionCeiling, |
| 59 | } |
| 60 | |
| 61 | /// The Reasoning Router service a snapshot is attached to. |
| 62 | /// |
| 63 | /// This is [`CapturedReasoningRouter`] under its historic name — the Router is |
| 64 | /// no longer a Fleet member, so the alias exists only to keep older call sites |
| 65 | /// and serialized shapes readable. |
| 66 | pub type FleetSnapshotRouter = CapturedReasoningRouter; |
| 67 | |
| 68 | /// A legacy fleet's role → profile binding, recorded for provenance. |
| 69 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 70 | pub struct FleetSnapshotLegacyRole { |
| 71 | pub role: String, |
| 72 | pub profile: String, |
| 73 | } |
| 74 | |
| 75 | /// The immutable value captured at Workflow start. |
| 76 | /// |
| 77 | /// Fields are private and there are no setters: once captured, the only way to |
| 78 | /// change a snapshot is to take a new one. |
| 79 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 80 | pub struct FleetSnapshot { |
| 81 | fleet: QualifiedFleetId, |
| 82 | schema_kind: String, |
| 83 | schema_revision: u32, |
| 84 | /// SHA-256 of the fleet definition bytes. |
| 85 | schema_hash: String, |
| 86 | /// SHA-256 over the captured members/routes/policies themselves, so two |
| 87 | /// snapshots can be compared without re-reading the source file. |
| 88 | content_hash: String, |
| 89 | members: Vec<FleetSnapshotMember>, |
| 90 | /// The attached Reasoning Router service, if this Fleet references one. |
| 91 | /// Resolved by the host (which owns the search roots) and handed in, so a |
| 92 | /// snapshot stays a pure value with no loader inside it. |
| 93 | router: Option<FleetSnapshotRouter>, |
| 94 | legacy_roles: Vec<FleetSnapshotLegacyRole>, |
| 95 | /// Caller-supplied timestamp; this crate has no clock. |
| 96 | captured_at: String, |
| 97 | } |
| 98 | |
| 99 | impl FleetSnapshot { |
| 100 | /// Capture a snapshot from a parsed fleet document and an already-resolved |
| 101 | /// Reasoning Router service. |
| 102 | /// |
| 103 | /// Exact rosters are **revalidated here**, not trusted. `ExactFleet` is |
| 104 | /// public and `Deserialize`, so a document can reach this point without |
| 105 | /// having passed the TOML parser's invariant checks; capture is the last |
| 106 | /// place to catch a duplicate role, an id/role collision, or a worker |
| 107 | /// claiming the Router's identity before those become a running Workflow. |
| 108 | /// |
| 109 | /// `router` is the captured service, whether it came from a saved reusable |
| 110 | /// profile or was normalized out of the legacy inline form. Resolution |
| 111 | /// happens in the host because it needs the fleet search roots; capture |
| 112 | /// only records the result. |
| 113 | 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 | /// Recompute the canonical content hash and reject a snapshot whose |
| 153 | /// recorded hash does not describe its own contents. |
| 154 | /// |
| 155 | /// `FleetSnapshot` is `Deserialize` and its `content_hash` is an ordinary |
| 156 | /// field, so a snapshot can reach a launch without ever having passed |
| 157 | /// [`Self::capture`] — through a replay file, a cache, or an IPC hop. That |
| 158 | /// hash is then stamped onto the durable receipt as the evidence that a run |
| 159 | /// matched a saved definition, so an unverified one is not weak evidence but |
| 160 | /// *false* evidence: it asserts a definition the members may not describe. |
| 161 | /// |
| 162 | /// Call this before anything durable or costly happens. It is cheap (one |
| 163 | /// canonical serialization plus a SHA-256) and it is the only thing standing |
| 164 | /// between a tampered or migrated snapshot and a receipt that vouches for |
| 165 | /// it. |
| 166 | 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 | /// [`Self::verify_content_hash`], as a guard that yields the snapshot. |
| 179 | /// |
| 180 | /// Exists so a load path cannot verify and then accidentally go on to use a |
| 181 | /// *different* value: the only thing this returns is the snapshot it just |
| 182 | /// checked. |
| 183 | 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 | // Hash only the captured shape, not the timestamp: two Workflows |
| 190 | // started from the same saved Fleet must agree. |
| 191 | #[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 | /// Look up a member by its **member id** — what addresses a roster entry. |
| 261 | #[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 | /// Look up a member by its **semantic role** — what gates, handoffs, and |
| 270 | /// records use. Kept separate from id lookup so a task can carry a |
| 271 | /// meaningful role while the runtime resolves a distinct profile id. |
| 272 | /// |
| 273 | /// Both sides resolve through [`canonical_role_key`], so a snapshot frozen |
| 274 | /// from a Fleet saved under a renamed role is still addressable by a gate or |
| 275 | /// handoff that spells the role the old way. |
| 276 | #[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 | /// Look up by id first, then by role. Roster invariants forbid an id/role |
| 285 | /// collision, so this can never be order-dependent. |
| 286 | #[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 | /// Whether any frozen member requested `auto` reasoning — i.e. whether this |
| 293 | /// Workflow needs a working Reasoning Router at all. |
| 294 | #[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 | /// Ids of the members that requested `auto`, for a startup error that names |
| 302 | /// who actually needs the Router. |
| 303 | #[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 | |
| 313 | fn exact_members(exact: &ExactFleet) -> Vec<FleetSnapshotMember> { |
| 314 | exact |
| 315 | .members |
| 316 | .iter() |
| 317 | .map(|member| FleetSnapshotMember { |
| 318 | id: canonical_member_key(&member.id), |
| 319 | // The snapshot is what every receipt is built from, so it records |
| 320 | // the *canonical* role even when the saved file used a renamed one. |
| 321 | // Old files keep working (lookup resolves either spelling); new |
| 322 | // receipts never print a name the current schema does not use. |
| 323 | 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 | |
| 331 | /// Verify a snapshot that arrived from anywhere other than [`FleetSnapshot::capture`]. |
| 332 | /// |
| 333 | /// The free function exists for load/deserialize seams that hold a snapshot by |
| 334 | /// reference and only need the yes/no answer — a durable-write guard, a replay |
| 335 | /// loader, a cache read. It is the same check as |
| 336 | /// [`FleetSnapshot::verify_content_hash`]; having a named entry point is what |
| 337 | /// lets those call sites read as "verify before use" rather than as an |
| 338 | /// incidental method call. |
| 339 | pub fn verify_snapshot_content_hash(snapshot: &FleetSnapshot) -> Result<(), ExactFleetError> { |
| 340 | snapshot.verify_content_hash() |
| 341 | } |
| 342 | |
| 343 | /// Normalize an exact Fleet's **legacy inline** Router into the captured |
| 344 | /// service, if it used the prototype form. |
| 345 | /// |
| 346 | /// A Fleet that references a saved profile resolves through |
| 347 | /// [`crate::ReasoningRouterProfile::load_by_name`] instead, in the host that |
| 348 | /// owns the search roots. Both paths land on the same value, which is the whole |
| 349 | /// point of keeping only one runtime representation. |
| 350 | #[must_use] |
| 351 | pub 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)] |
| 358 | mod content_hash_tests { |
| 359 | use super::*; |
| 360 | |
| 361 | const EXACT_FLEET: &str = r#" |
| 362 | name = "glm-pair" |
| 363 | schema = "exact" |
| 364 | |
| 365 | [[members]] |
| 366 | id = "implementer" |
| 367 | role = "builder" |
| 368 | provider = "zai" |
| 369 | model = "glm-5" |
| 370 | reasoning = "high" |
| 371 | permissions = "read_write" |
| 372 | |
| 373 | [[members]] |
| 374 | id = "advisor-one" |
| 375 | role = "oracle" |
| 376 | provider = "zai" |
| 377 | model = "glm-5" |
| 378 | reasoning = "low" |
| 379 | permissions = "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 | /// The round trip a replay file, a cache read, or an IPC hop performs. An |
| 405 | /// untouched snapshot must survive it — otherwise the guard below would be |
| 406 | /// unusable at exactly the seams it exists for. |
| 407 | #[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 | /// The tamper case. A snapshot whose members were edited after capture |
| 418 | /// keeps its old hash, and that hash is what a receipt would vouch for. |
| 419 | /// Verification must reject it *before* any launch or durable write. |
| 420 | #[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 | // Widen a member's route — the single most consequential edit, and the |
| 427 | // one a stale hash would silently certify. |
| 428 | 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 | /// Widening a permission ceiling is the tamper that matters most, since the |
| 447 | /// receipt's fingerprint is computed from the ceiling this snapshot carries. |
| 448 | #[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 | /// A forged hash fails the same way an edited body does: the check is a |
| 459 | /// recomputation, not a presence test, so neither side can be trusted alone. |
| 460 | #[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 | /// The migration case: a snapshot written by an older build that recorded a |
| 471 | /// renamed role verbatim. Capture now canonicalizes, so the *stored* role is |
| 472 | /// `consultant` and the hash covers that — an old snapshot carrying |
| 473 | /// `oracle` cannot pass verification and must be re-captured rather than |
| 474 | /// quietly relabelled at read time. |
| 475 | #[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 | // The alias still *resolves* — compatibility is a lookup property, not a |
| 490 | // licence to accept an unverified hash. |
| 491 | assert!(migrated.member_by_role("consultant").is_some()); |
| 492 | } |
| 493 | |
| 494 | /// Lookup canonicalization survives capture: a snapshot frozen from a Fleet |
| 495 | /// saved under the old name answers to either spelling. |
| 496 | #[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)] |
| 514 | mod 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 | /// A Fleet that references a saved, reusable Router profile — the shape new |
| 523 | /// Fleets use. |
| 524 | const EXACT: &str = r#" |
| 525 | name = "glm-pair" |
| 526 | schema = "exact" |
| 527 | reasoning_router = "luna-low" |
| 528 | |
| 529 | [[members]] |
| 530 | id = "implementer" |
| 531 | role = "builder" |
| 532 | provider = "zai" |
| 533 | model = "glm-5" |
| 534 | reasoning = "auto" |
| 535 | permissions = "read_write" |
| 536 | |
| 537 | [[members]] |
| 538 | id = "auditor" |
| 539 | provider = "zai" |
| 540 | model = "glm-5" |
| 541 | reasoning = "high" |
| 542 | permissions = "read_only" |
| 543 | "#; |
| 544 | |
| 545 | /// The prototype form, retained for compatibility. |
| 546 | const LEGACY_INLINE: &str = r#" |
| 547 | name = "glm-pair" |
| 548 | schema = "exact" |
| 549 | |
| 550 | [[members]] |
| 551 | id = "implementer" |
| 552 | role = "builder" |
| 553 | provider = "zai" |
| 554 | model = "glm-5" |
| 555 | reasoning = "auto" |
| 556 | permissions = "read_write" |
| 557 | |
| 558 | [[members]] |
| 559 | id = "router" |
| 560 | kind = "router" |
| 561 | provider = "zai" |
| 562 | model = "glm-5-turbo" |
| 563 | "#; |
| 564 | |
| 565 | const LEGACY_ROLE_MAP: &str = r#" |
| 566 | name = "stopship" |
| 567 | description = "legacy roster" |
| 568 | |
| 569 | [roles] |
| 570 | scout = "scout" |
| 571 | implementer = "builder" |
| 572 | "#; |
| 573 | |
| 574 | const LUNA: &str = r#" |
| 575 | name = "luna-low" |
| 576 | schema = "reasoning_router" |
| 577 | provider = "openai" |
| 578 | model = "gpt-5.6-luna" |
| 579 | call_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 | // Roles and ids are separate lookups, and both find the same member. |
| 610 | 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 | // An id lookup must not answer to a role, or a task naming one would |
| 619 | // silently resolve the other. |
| 620 | 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 | /// The Router is a referenced service, not a Fleet member: it holds no |
| 628 | /// authority, is never dispatchable, and is not in the roster. |
| 629 | #[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 | // Not reachable through worker lookup by either id or role. |
| 648 | 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 | /// One saved profile, two different Fleets. The service is referenced, not |
| 654 | /// owned, so both snapshots capture the identical value. |
| 655 | #[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 | /// The prototype inline form normalizes into the same captured service, so |
| 681 | /// nothing downstream has to know which way the operator wrote it. |
| 682 | #[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 | // The inline member is not in the roster. |
| 705 | 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 | // The operator edits the saved file mid-run: different model, different |
| 714 | // reasoning, wider permissions. |
| 715 | 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 | // The in-flight snapshot is untouched. |
| 724 | 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 | // The next run sees the edit, and the hashes prove they differ. |
| 730 | 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 | // Different capture time, same fleet: the content hash must not move. |
| 741 | 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 | /// Swapping the attached Router is a real change to what will run, so it |
| 749 | /// must move the content hash. |
| 750 | #[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 | // Round-trips as a durable value. |
| 809 | let back: FleetSnapshot = serde_json::from_str(&json).expect("deserialize"); |
| 810 | assert_eq!(back, snapshot); |
| 811 | } |
| 812 | |
| 813 | /// A durable snapshot identifies its definition by qualified origin/name |
| 814 | /// and by hash — never by a filesystem path, which would leak the |
| 815 | /// operator's home directory and username into anything that stores it. |
| 816 | #[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 | // The document still knows where it came from, for local diagnostics. |
| 824 | 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 | /// Capture is the last gate before a roster becomes a running Workflow, so |
| 843 | /// a value that never saw the TOML parser must still be rejected here. |
| 844 | #[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 | // Two members, one role: role lookup would resolve by list order. |
| 862 | 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 | } |
| 875 |