返回 CodeWhale
fleet_snapshot.rs
根目录 / crates / workflow / src / fleet_snapshot.rs
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
875 lines RUST