返回 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. Authority is absent by design: Runtime derives it from
7 //! the selected member's Runtime role and the live parent. Editing the saved file
8 //! afterwards changes only future runs — the snapshot in flight is unaffected,
9 //! because it owns copies and exposes no mutators.
10 //!
11 //! **No-secrets invariant**: every field here is a non-sensitive id, model
12 //! string, tier label, or boolean. There is deliberately no field that could
13 //! hold a credential, token, or base URL.
14
15 use serde::{Deserialize, Serialize};
16
17 use crate::fleet_exact::{
18 ExactFleet, ExactFleetError, FrozenRoute, PermissionCeiling, RequestedReasoning,
19 canonical_member_key, canonical_role_key,
20 };
21 use crate::named_fleet::{FleetDocument, FleetSchema};
22 use crate::reasoning_router::CapturedReasoningRouter;
23
24 /// A Fleet identity qualified by where the definition came from.
25 ///
26 /// Deliberately **path-free**. An absolute filesystem path in a durable receipt
27 /// leaks the operator's home directory, username, and machine layout into
28 /// journals and events that travel further than the machine that wrote them.
29 /// `origin/name` plus the schema/content hashes identify a definition precisely
30 /// enough to compare two runs, without any of that. Local diagnostic errors
31 /// (fleet not found, ambiguous fleet) still name paths — those are read on the
32 /// machine that produced them and never persisted onto a receipt.
33 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34 pub struct QualifiedFleetId {
35 /// Fleet name as declared in the file.
36 pub name: String,
37 /// Non-secret origin label, e.g. `workspace` or `codewhale_home`.
38 pub origin: String,
39 }
40
41 impl QualifiedFleetId {
42 /// `origin/name` — the stable display form.
43 #[must_use]
44 pub fn qualified(&self) -> String {
45 format!("{}/{}", self.origin, self.name)
46 }
47 }
48
49 /// One member as frozen into the snapshot.
50 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51 pub struct FleetSnapshotMember {
52 pub id: String,
53 pub role: String,
54 /// The exact route, frozen before any reasoning resolution.
55 pub route: FrozenRoute,
56 /// The reasoning policy the member requested (not the effective tier —
57 /// that is resolved per run and recorded on the receipt).
58 pub requested_reasoning: RequestedReasoning,
59 /// Prototype snapshot compatibility only.
60 ///
61 /// New captures leave this absent. Old replay snapshots need the historic
62 /// value solely to verify their original content hash; selection and
63 /// Runtime authority never read it. Re-serializing a verified old snapshot
64 /// preserves the field so its evidence remains round-trippable, while a
65 /// fresh recapture emits the canonical authority-free shape.
66 #[serde(
67 default,
68 rename = "permissions",
69 skip_serializing_if = "Option::is_none"
70 )]
71 legacy_permissions: Option<PermissionCeiling>,
72 }
73
74 /// The Reasoning Router service a snapshot is attached to.
75 ///
76 /// This is [`CapturedReasoningRouter`] under its historic name — the Router is
77 /// no longer a Fleet member, so the alias exists only to keep older call sites
78 /// and serialized shapes readable.
79 pub type FleetSnapshotRouter = CapturedReasoningRouter;
80
81 /// A legacy fleet's role → profile binding, recorded for provenance.
82 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83 pub struct FleetSnapshotLegacyRole {
84 pub role: String,
85 pub profile: String,
86 }
87
88 /// The immutable value captured at Workflow start.
89 ///
90 /// Fields are private and there are no setters: once captured, the only way to
91 /// change a snapshot is to take a new one.
92 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93 pub struct FleetSnapshot {
94 fleet: QualifiedFleetId,
95 schema_kind: String,
96 schema_revision: u32,
97 /// SHA-256 of the fleet definition bytes.
98 schema_hash: String,
99 /// SHA-256 over the captured members/routes/policies themselves, so two
100 /// snapshots can be compared without re-reading the source file.
101 content_hash: String,
102 members: Vec<FleetSnapshotMember>,
103 /// The attached Reasoning Router service, if this Fleet references one.
104 /// Resolved by the host (which owns the search roots) and handed in, so a
105 /// snapshot stays a pure value with no loader inside it.
106 router: Option<FleetSnapshotRouter>,
107 legacy_roles: Vec<FleetSnapshotLegacyRole>,
108 /// Caller-supplied timestamp; this crate has no clock.
109 captured_at: String,
110 }
111
112 impl FleetSnapshot {
113 /// Capture a snapshot from a parsed fleet document and an already-resolved
114 /// Reasoning Router service.
115 ///
116 /// Exact rosters are **revalidated here**, not trusted. `ExactFleet` is
117 /// public and `Deserialize`, so a document can reach this point without
118 /// having passed the TOML parser's invariant checks; capture is the last
119 /// place to catch a duplicate role, an id/role collision, or a worker
120 /// claiming the Router's identity before those become a running Workflow.
121 ///
122 /// `router` is the captured service, whether it came from a saved reusable
123 /// profile or was normalized out of the legacy inline form. Resolution
124 /// happens in the host because it needs the fleet search roots; capture
125 /// only records the result.
126 pub fn capture(
127 fleet: QualifiedFleetId,
128 document: &FleetDocument,
129 captured_at: impl Into<String>,
130 router: Option<CapturedReasoningRouter>,
131 ) -> Result<Self, ExactFleetError> {
132 let (members, legacy_roles) = match document.schema() {
133 FleetSchema::Exact(exact) => {
134 exact.validate()?;
135 (exact_members(exact), Vec::new())
136 }
137 FleetSchema::Legacy(legacy) => (
138 Vec::new(),
139 legacy
140 .roles
141 .iter()
142 .map(|(role, profile)| FleetSnapshotLegacyRole {
143 role: role.clone(),
144 profile: profile.clone(),
145 })
146 .collect(),
147 ),
148 };
149
150 let mut snapshot = Self {
151 fleet,
152 schema_kind: document.schema_kind().to_string(),
153 schema_revision: document.schema_revision(),
154 schema_hash: document.source_hash().to_string(),
155 content_hash: String::new(),
156 members,
157 router,
158 legacy_roles,
159 captured_at: captured_at.into(),
160 };
161 snapshot.content_hash = snapshot.compute_content_hash();
162 Ok(snapshot)
163 }
164
165 /// Recompute the canonical content hash and reject a snapshot whose
166 /// recorded hash does not describe its own contents.
167 ///
168 /// `FleetSnapshot` is `Deserialize` and its `content_hash` is an ordinary
169 /// field, so a snapshot can reach a launch without ever having passed
170 /// [`Self::capture`] — through a replay file, a cache, or an IPC hop. That
171 /// hash is then stamped onto the durable receipt as the evidence that a run
172 /// matched a saved definition, so an unverified one is not weak evidence but
173 /// *false* evidence: it asserts a definition the members may not describe.
174 ///
175 /// Call this before anything durable or costly happens. It is cheap (one
176 /// canonical serialization plus a SHA-256) and it is the only thing standing
177 /// between a tampered or migrated snapshot and a receipt that vouches for
178 /// it.
179 pub fn verify_content_hash(&self) -> Result<(), ExactFleetError> {
180 let recomputed = if self
181 .members
182 .iter()
183 .any(|member| member.legacy_permissions.is_some())
184 {
185 // Once an old authority-bearing field is present, it must be
186 // covered by the historic hash. Accepting the new canonical hash
187 // here would let arbitrary compatibility bytes hitchhike on an
188 // otherwise valid snapshot without verification.
189 self.compute_legacy_content_hash().ok_or_else(|| {
190 ExactFleetError::ContentHashMismatch {
191 fleet: self.fleet.qualified(),
192 recorded: self.content_hash.clone(),
193 recomputed: "unverifiable mixed legacy-permissions shape".to_string(),
194 }
195 })?
196 } else {
197 self.compute_content_hash()
198 };
199 if recomputed == self.content_hash {
200 return Ok(());
201 }
202 Err(ExactFleetError::ContentHashMismatch {
203 fleet: self.fleet.qualified(),
204 recorded: self.content_hash.clone(),
205 recomputed,
206 })
207 }
208
209 /// [`Self::verify_content_hash`], as a guard that yields the snapshot.
210 ///
211 /// Exists so a load path cannot verify and then accidentally go on to use a
212 /// *different* value: the only thing this returns is the snapshot it just
213 /// checked.
214 pub fn into_verified(self) -> Result<Self, ExactFleetError> {
215 self.verify_content_hash()?;
216 Ok(self)
217 }
218
219 fn compute_content_hash(&self) -> String {
220 // Hash only the captured shape, not the timestamp: two Workflows
221 // started from the same saved Fleet must agree.
222 #[derive(Serialize)]
223 struct CanonicalMember<'a> {
224 id: &'a str,
225 role: &'a str,
226 route: &'a FrozenRoute,
227 requested_reasoning: RequestedReasoning,
228 }
229 #[derive(Serialize)]
230 struct Shape<'a> {
231 fleet: &'a QualifiedFleetId,
232 schema_kind: &'a str,
233 schema_revision: u32,
234 schema_hash: &'a str,
235 members: Vec<CanonicalMember<'a>>,
236 router: &'a Option<FleetSnapshotRouter>,
237 legacy_roles: &'a [FleetSnapshotLegacyRole],
238 }
239
240 let shape = Shape {
241 fleet: &self.fleet,
242 schema_kind: &self.schema_kind,
243 schema_revision: self.schema_revision,
244 schema_hash: &self.schema_hash,
245 members: self
246 .members
247 .iter()
248 .map(|member| CanonicalMember {
249 id: &member.id,
250 role: &member.role,
251 route: &member.route,
252 requested_reasoning: member.requested_reasoning,
253 })
254 .collect(),
255 router: &self.router,
256 legacy_roles: &self.legacy_roles,
257 };
258 let encoded = serde_json::to_vec(&shape).expect("snapshot shape is serializable");
259 crate::named_fleet::sha256_label(&encoded)
260 }
261
262 /// Recompute the prototype content hash when (and only when) every exact
263 /// member carried its historic permission field. This validates old
264 /// evidence without projecting that field into current authority.
265 fn compute_legacy_content_hash(&self) -> Option<String> {
266 #[derive(Serialize)]
267 struct LegacyMember<'a> {
268 id: &'a str,
269 role: &'a str,
270 route: &'a FrozenRoute,
271 requested_reasoning: RequestedReasoning,
272 permissions: PermissionCeiling,
273 }
274 #[derive(Serialize)]
275 struct Shape<'a> {
276 fleet: &'a QualifiedFleetId,
277 schema_kind: &'a str,
278 schema_revision: u32,
279 schema_hash: &'a str,
280 members: Vec<LegacyMember<'a>>,
281 router: &'a Option<FleetSnapshotRouter>,
282 legacy_roles: &'a [FleetSnapshotLegacyRole],
283 }
284
285 if self.members.is_empty()
286 || self
287 .members
288 .iter()
289 .any(|member| member.legacy_permissions.is_none())
290 {
291 return None;
292 }
293 let shape = Shape {
294 fleet: &self.fleet,
295 schema_kind: &self.schema_kind,
296 schema_revision: self.schema_revision,
297 schema_hash: &self.schema_hash,
298 members: self
299 .members
300 .iter()
301 .map(|member| LegacyMember {
302 id: &member.id,
303 role: &member.role,
304 route: &member.route,
305 requested_reasoning: member.requested_reasoning,
306 permissions: member
307 .legacy_permissions
308 .expect("checked every legacy permission above"),
309 })
310 .collect(),
311 router: &self.router,
312 legacy_roles: &self.legacy_roles,
313 };
314 let encoded = serde_json::to_vec(&shape).expect("legacy snapshot shape is serializable");
315 Some(crate::named_fleet::sha256_label(&encoded))
316 }
317
318 #[must_use]
319 pub fn fleet(&self) -> &QualifiedFleetId {
320 &self.fleet
321 }
322
323 #[must_use]
324 pub fn schema_kind(&self) -> &str {
325 &self.schema_kind
326 }
327
328 #[must_use]
329 pub const fn schema_revision(&self) -> u32 {
330 self.schema_revision
331 }
332
333 #[must_use]
334 pub fn schema_hash(&self) -> &str {
335 &self.schema_hash
336 }
337
338 #[must_use]
339 pub fn content_hash(&self) -> &str {
340 &self.content_hash
341 }
342
343 #[must_use]
344 pub fn members(&self) -> &[FleetSnapshotMember] {
345 &self.members
346 }
347
348 #[must_use]
349 pub fn router(&self) -> Option<&FleetSnapshotRouter> {
350 self.router.as_ref()
351 }
352
353 #[must_use]
354 pub fn legacy_roles(&self) -> &[FleetSnapshotLegacyRole] {
355 &self.legacy_roles
356 }
357
358 #[must_use]
359 pub fn captured_at(&self) -> &str {
360 &self.captured_at
361 }
362
363 /// Look up a member by its **member id** — what addresses a roster entry.
364 #[must_use]
365 pub fn member(&self, id: &str) -> Option<&FleetSnapshotMember> {
366 let key = canonical_member_key(id);
367 self.members
368 .iter()
369 .find(|member| canonical_member_key(&member.id) == key)
370 }
371
372 /// Look up a member by its **semantic role** — what gates, handoffs, and
373 /// records use. Kept separate from id lookup so a task can carry a
374 /// meaningful role while the runtime resolves a distinct profile id.
375 ///
376 /// Both sides resolve through [`canonical_role_key`], so a snapshot frozen
377 /// from a Fleet saved under a renamed role is still addressable by a gate or
378 /// handoff that spells the role the old way.
379 #[must_use]
380 pub fn member_by_role(&self, role: &str) -> Option<&FleetSnapshotMember> {
381 let key = canonical_role_key(role);
382 self.members
383 .iter()
384 .find(|member| canonical_role_key(&member.role) == key)
385 }
386
387 /// Look up by id first, then by role. Roster invariants forbid an id/role
388 /// collision, so this can never be order-dependent.
389 #[must_use]
390 pub fn member_by_id_or_role(&self, id_or_role: &str) -> Option<&FleetSnapshotMember> {
391 self.member(id_or_role)
392 .or_else(|| self.member_by_role(id_or_role))
393 }
394
395 /// Whether any frozen member requested `auto` reasoning — i.e. whether this
396 /// Workflow needs a working Reasoning Router at all.
397 #[must_use]
398 pub fn has_auto_member(&self) -> bool {
399 self.members
400 .iter()
401 .any(|member| member.requested_reasoning.is_auto())
402 }
403
404 /// Ids of the members that requested `auto`, for a startup error that names
405 /// who actually needs the Router.
406 #[must_use]
407 pub fn auto_member_ids(&self) -> Vec<String> {
408 self.members
409 .iter()
410 .filter(|member| member.requested_reasoning.is_auto())
411 .map(|member| member.id.clone())
412 .collect()
413 }
414 }
415
416 fn exact_members(exact: &ExactFleet) -> Vec<FleetSnapshotMember> {
417 exact
418 .members
419 .iter()
420 .map(|member| FleetSnapshotMember {
421 id: canonical_member_key(&member.id),
422 // The snapshot is what every receipt is built from, so it records
423 // the *canonical* role even when the saved file used a renamed one.
424 // Old files keep working (lookup resolves either spelling); new
425 // receipts never print a name the current schema does not use.
426 role: canonical_role_key(&member.role),
427 route: member.frozen_route(),
428 requested_reasoning: member.reasoning,
429 legacy_permissions: None,
430 })
431 .collect()
432 }
433
434 /// Verify a snapshot that arrived from anywhere other than [`FleetSnapshot::capture`].
435 ///
436 /// The free function exists for load/deserialize seams that hold a snapshot by
437 /// reference and only need the yes/no answer — a durable-write guard, a replay
438 /// loader, a cache read. It is the same check as
439 /// [`FleetSnapshot::verify_content_hash`]; having a named entry point is what
440 /// lets those call sites read as "verify before use" rather than as an
441 /// incidental method call.
442 pub fn verify_snapshot_content_hash(snapshot: &FleetSnapshot) -> Result<(), ExactFleetError> {
443 snapshot.verify_content_hash()
444 }
445
446 /// Normalize an exact Fleet's **legacy inline** Router into the captured
447 /// service, if it used the prototype form.
448 ///
449 /// A Fleet that references a saved profile resolves through
450 /// [`crate::ReasoningRouterProfile::load_by_name`] instead, in the host that
451 /// owns the search roots. Both paths land on the same value, which is the whole
452 /// point of keeping only one runtime representation.
453 #[must_use]
454 pub fn captured_legacy_inline_router(exact: &ExactFleet) -> Option<CapturedReasoningRouter> {
455 exact
456 .legacy_inline_router()
457 .map(CapturedReasoningRouter::from_legacy_inline)
458 }
459
460 #[cfg(test)]
461 mod content_hash_tests {
462 use super::*;
463
464 const EXACT_FLEET: &str = r#"
465 name = "glm-pair"
466 schema = "exact"
467
468 [[members]]
469 id = "implementer"
470 role = "builder"
471 provider = "zai"
472 model = "glm-5"
473 reasoning = "high"
474 permissions = "read_write"
475
476 [[members]]
477 id = "advisor-one"
478 role = "oracle"
479 provider = "zai"
480 model = "glm-5"
481 reasoning = "low"
482 permissions = "analyst"
483 "#;
484
485 fn captured() -> FleetSnapshot {
486 let document = FleetDocument::parse(EXACT_FLEET).expect("parse fleet document");
487 FleetSnapshot::capture(
488 QualifiedFleetId {
489 name: "glm-pair".to_string(),
490 origin: "workspace".to_string(),
491 },
492 &document,
493 "2026-07-26T00:00:00Z",
494 None,
495 )
496 .expect("capture")
497 }
498
499 #[test]
500 fn a_freshly_captured_snapshot_verifies() {
501 let snapshot = captured();
502 assert!(snapshot.verify_content_hash().is_ok());
503 assert!(verify_snapshot_content_hash(&snapshot).is_ok());
504 assert!(snapshot.into_verified().is_ok());
505 }
506
507 /// The round trip a replay file, a cache read, or an IPC hop performs. An
508 /// untouched snapshot must survive it — otherwise the guard below would be
509 /// unusable at exactly the seams it exists for.
510 #[test]
511 fn an_untouched_round_trip_still_verifies() {
512 let snapshot = captured();
513 let encoded = serde_json::to_string(&snapshot).expect("serialize");
514 let decoded: FleetSnapshot = serde_json::from_str(&encoded).expect("deserialize");
515
516 assert_eq!(decoded, snapshot);
517 assert!(decoded.verify_content_hash().is_ok());
518 }
519
520 /// The tamper case. A snapshot whose members were edited after capture
521 /// keeps its old hash, and that hash is what a receipt would vouch for.
522 /// Verification must reject it *before* any launch or durable write.
523 #[test]
524 fn an_edited_member_is_rejected_while_the_hash_still_claims_the_original() {
525 let snapshot = captured();
526 let original_hash = snapshot.content_hash().to_string();
527
528 let mut value = serde_json::to_value(&snapshot).expect("serialize");
529 // Widen a member's route — the single most consequential edit, and the
530 // one a stale hash would silently certify.
531 value["members"][0]["route"]["model"] = serde_json::json!("glm-5-max");
532 let tampered: FleetSnapshot = serde_json::from_value(value).expect("deserialize");
533
534 assert_eq!(
535 tampered.content_hash(),
536 original_hash,
537 "the tamper does not touch the recorded hash — that is the point"
538 );
539 let error = tampered
540 .verify_content_hash()
541 .expect_err("a tampered snapshot must not verify");
542 assert!(matches!(
543 error,
544 ExactFleetError::ContentHashMismatch { ref recorded, .. } if *recorded == original_hash
545 ));
546 assert!(tampered.into_verified().is_err());
547 }
548
549 /// Prototype snapshots carried member `permissions`. A valid old content
550 /// hash remains verifiable for replay, but the field is compatibility
551 /// evidence only and a fresh capture emits the authority-free shape.
552 #[test]
553 fn a_valid_legacy_permission_snapshot_verifies_and_round_trips() {
554 let snapshot = captured();
555 let mut value = serde_json::to_value(&snapshot).expect("serialize");
556 for index in 0..2 {
557 value["members"][index]["permissions"] = serde_json::json!({
558 "write": index == 0,
559 "network_tool": false,
560 "shell": "read_only",
561 "delegation_depth": 0,
562 "tools": true
563 });
564 }
565 let mut replay: FleetSnapshot = serde_json::from_value(value).expect("legacy replay loads");
566 replay.content_hash = replay
567 .compute_legacy_content_hash()
568 .expect("old shape has a legacy hash");
569
570 assert_ne!(replay.compute_content_hash(), replay.content_hash);
571 assert!(replay.verify_content_hash().is_ok());
572 let encoded = serde_json::to_string(&replay).expect("legacy replay remains durable");
573 assert!(encoded.contains("\"permissions\""), "{encoded}");
574 let decoded: FleetSnapshot = serde_json::from_str(&encoded).expect("round trip");
575 assert!(decoded.verify_content_hash().is_ok());
576 }
577
578 #[test]
579 fn a_tampered_legacy_permission_snapshot_fails_closed() {
580 let snapshot = captured();
581 let mut value = serde_json::to_value(&snapshot).expect("serialize");
582 for index in 0..2 {
583 value["members"][index]["permissions"] = serde_json::json!({
584 "write": false,
585 "network_tool": false,
586 "shell": "read_only",
587 "delegation_depth": 0,
588 "tools": true
589 });
590 }
591 let mut replay: FleetSnapshot = serde_json::from_value(value).expect("legacy replay loads");
592 replay.content_hash = replay
593 .compute_legacy_content_hash()
594 .expect("old shape has a legacy hash");
595 let recorded = replay.content_hash.clone();
596
597 let mut tampered = serde_json::to_value(&replay).expect("serialize old replay");
598 tampered["members"][0]["permissions"]["write"] = serde_json::json!(true);
599 let tampered: FleetSnapshot = serde_json::from_value(tampered).expect("deserialize");
600 assert_eq!(tampered.content_hash(), recorded);
601 assert!(tampered.verify_content_hash().is_err());
602 }
603
604 #[test]
605 fn legacy_permission_bytes_must_be_covered_by_the_legacy_hash() {
606 let snapshot = captured();
607 let canonical_hash = snapshot.content_hash().to_string();
608 let mut value = serde_json::to_value(&snapshot).expect("serialize");
609 for index in 0..2 {
610 value["members"][index]["permissions"] = serde_json::json!({
611 "write": false,
612 "network_tool": false,
613 "shell": "read_only",
614 "delegation_depth": 0,
615 "tools": true
616 });
617 }
618 let replay: FleetSnapshot = serde_json::from_value(value).expect("legacy shape loads");
619
620 assert_eq!(replay.content_hash(), canonical_hash);
621 assert!(
622 replay.verify_content_hash().is_err(),
623 "legacy bytes may not hitchhike on the authority-free canonical hash"
624 );
625 }
626
627 /// A forged hash fails the same way an edited body does: the check is a
628 /// recomputation, not a presence test, so neither side can be trusted alone.
629 #[test]
630 fn a_forged_hash_is_rejected() {
631 let snapshot = captured();
632 let mut value = serde_json::to_value(&snapshot).expect("serialize");
633 value["content_hash"] = serde_json::json!("0".repeat(64));
634 let forged: FleetSnapshot = serde_json::from_value(value).expect("deserialize");
635
636 assert!(forged.verify_content_hash().is_err());
637 }
638
639 /// The migration case: a snapshot written by an older build that recorded a
640 /// renamed role verbatim. Capture now canonicalizes, so the *stored* role is
641 /// `consultant` and the hash covers that — an old snapshot carrying
642 /// `oracle` cannot pass verification and must be re-captured rather than
643 /// quietly relabelled at read time.
644 #[test]
645 fn a_pre_rename_snapshot_is_rejected_rather_than_silently_relabelled() {
646 let snapshot = captured();
647 assert_eq!(
648 snapshot.members()[1].role,
649 "consultant",
650 "capture records the canonical role"
651 );
652
653 let mut value = serde_json::to_value(&snapshot).expect("serialize");
654 value["members"][1]["role"] = serde_json::json!("oracle");
655 let migrated: FleetSnapshot = serde_json::from_value(value).expect("deserialize");
656
657 assert!(migrated.verify_content_hash().is_err());
658 // The alias still *resolves* — compatibility is a lookup property, not a
659 // licence to accept an unverified hash.
660 assert!(migrated.member_by_role("consultant").is_some());
661 }
662
663 /// Lookup canonicalization survives capture: a snapshot frozen from a Fleet
664 /// saved under the old name answers to either spelling.
665 #[test]
666 fn snapshot_role_lookup_accepts_both_spellings() {
667 let snapshot = captured();
668
669 for spelling in ["consultant", "oracle", "advisor", "ORACLE"] {
670 assert_eq!(
671 snapshot
672 .member_by_role(spelling)
673 .unwrap_or_else(|| panic!("`{spelling}` must resolve"))
674 .id,
675 "advisor-one"
676 );
677 }
678 assert!(snapshot.member_by_id_or_role("oracle").is_some());
679 }
680 }
681
682 #[cfg(test)]
683 mod tests {
684 use super::*;
685 use crate::fleet_exact::ShellCeiling;
686 use crate::reasoning_router::{
687 LEGACY_INLINE_ROUTER_ORIGIN, REASONING_ROUTER_SERVICE_KIND, ReasoningRouterProfile,
688 RouterCallReasoning,
689 };
690
691 /// A Fleet that references a saved, reusable Router profile — the shape new
692 /// Fleets use.
693 const EXACT: &str = r#"
694 name = "glm-pair"
695 schema = "exact"
696 reasoning_router = "luna-low"
697
698 [[members]]
699 id = "implementer"
700 role = "builder"
701 provider = "zai"
702 model = "glm-5"
703 reasoning = "auto"
704 permissions = "read_write"
705
706 [[members]]
707 id = "auditor"
708 provider = "zai"
709 model = "glm-5"
710 reasoning = "high"
711 permissions = "read_only"
712 "#;
713
714 /// The prototype form, retained for compatibility.
715 const LEGACY_INLINE: &str = r#"
716 name = "glm-pair"
717 schema = "exact"
718
719 [[members]]
720 id = "implementer"
721 role = "builder"
722 provider = "zai"
723 model = "glm-5"
724 reasoning = "auto"
725 permissions = "read_write"
726
727 [[members]]
728 id = "router"
729 kind = "router"
730 provider = "zai"
731 model = "glm-5-turbo"
732 "#;
733
734 const LEGACY_ROLE_MAP: &str = r#"
735 name = "stopship"
736 description = "legacy roster"
737
738 [roles]
739 scout = "scout"
740 implementer = "builder"
741 "#;
742
743 const LUNA: &str = r#"
744 name = "luna-low"
745 schema = "reasoning_router"
746 provider = "openai"
747 model = "gpt-5.6-luna"
748 call_reasoning = "low"
749 "#;
750
751 fn id() -> QualifiedFleetId {
752 QualifiedFleetId {
753 name: "glm-pair".to_string(),
754 origin: "workspace".to_string(),
755 }
756 }
757
758 fn luna() -> CapturedReasoningRouter {
759 let profile = ReasoningRouterProfile::parse(LUNA).expect("router profile");
760 CapturedReasoningRouter::from_profile(&profile, "workspace")
761 }
762
763 fn capture(text: &str, router: Option<CapturedReasoningRouter>) -> FleetSnapshot {
764 let document = FleetDocument::parse(text).expect("parse");
765 FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", router).expect("capture")
766 }
767
768 #[test]
769 fn snapshot_captures_identity_schema_routes_and_reasoning() {
770 let snapshot = capture(EXACT, Some(luna()));
771
772 assert_eq!(snapshot.fleet().qualified(), "workspace/glm-pair");
773 assert_eq!(snapshot.schema_kind(), "exact");
774 assert_eq!(snapshot.schema_revision(), 1);
775 assert!(snapshot.schema_hash().starts_with("sha256:"));
776 assert!(snapshot.content_hash().starts_with("sha256:"));
777
778 // Roles and ids are separate lookups, and both find the same member.
779 let by_role = snapshot.member_by_role("builder").expect("role lookup");
780 let by_id = snapshot.member("implementer").expect("id lookup");
781 assert_eq!(by_role.id, by_id.id);
782 assert_eq!(by_id.route.provider, "zai");
783 assert_eq!(by_id.route.model, "glm-5");
784 assert_eq!(by_id.requested_reasoning, RequestedReasoning::Auto);
785 let member_json = serde_json::to_value(by_id).expect("serialize member");
786 assert!(member_json.get("permissions").is_none(), "{member_json}");
787
788 // An id lookup must not answer to a role, or a task naming one would
789 // silently resolve the other.
790 assert!(snapshot.member("builder").is_none());
791 assert!(snapshot.member_by_role("implementer").is_none());
792
793 assert!(snapshot.has_auto_member());
794 assert_eq!(snapshot.auto_member_ids(), vec!["implementer".to_string()]);
795 }
796
797 /// The Router is a referenced service, not a Fleet member: it holds no
798 /// authority, is never dispatchable, and is not in the roster.
799 #[test]
800 fn the_attached_router_is_a_service_and_not_a_roster_member() {
801 let snapshot = capture(EXACT, Some(luna()));
802 let router = snapshot.router().expect("router service");
803
804 assert_eq!(router.service_kind, REASONING_ROUTER_SERVICE_KIND);
805 assert_eq!(router.qualified(), "workspace/luna-low");
806 assert!(!router.legacy_inline);
807 assert!(!router.is_dispatchable());
808 assert!(!router.dispatchable);
809 assert!(router.tool_surface().is_empty());
810 assert_eq!(router.route.provider, "openai");
811 assert_eq!(router.route.model, "gpt-5.6-luna");
812 assert_eq!(router.requested_call_reasoning, RouterCallReasoning::Low);
813 assert_eq!(router.permissions.shell, ShellCeiling::None);
814 assert!(!router.permissions.tools);
815 assert_eq!(router.permissions.delegation_depth, 0);
816
817 // Not reachable through worker lookup by either id or role.
818 assert!(snapshot.member("luna-low").is_none());
819 assert!(snapshot.member_by_role("luna-low").is_none());
820 assert!(snapshot.member_by_id_or_role("router").is_none());
821 }
822
823 /// One saved profile, two different Fleets. The service is referenced, not
824 /// owned, so both snapshots capture the identical value.
825 #[test]
826 fn one_router_profile_serves_two_fleets() {
827 let first = capture(EXACT, Some(luna()));
828 let second_text = EXACT.replace("name = \"glm-pair\"", "name = \"other-pair\"");
829 let document = FleetDocument::parse(&second_text).expect("parse");
830 let second = FleetSnapshot::capture(
831 QualifiedFleetId {
832 name: "other-pair".to_string(),
833 origin: "workspace".to_string(),
834 },
835 &document,
836 "2026-07-26T00:00:00Z",
837 Some(luna()),
838 )
839 .expect("capture");
840
841 assert_eq!(first.router(), second.router());
842 assert_ne!(first.fleet(), second.fleet());
843 assert_ne!(
844 first.content_hash(),
845 second.content_hash(),
846 "different fleets are still different snapshots"
847 );
848 }
849
850 /// The prototype inline form normalizes into the same captured service, so
851 /// nothing downstream has to know which way the operator wrote it.
852 #[test]
853 fn a_legacy_inline_router_normalizes_into_the_same_captured_service() {
854 let document = FleetDocument::parse(LEGACY_INLINE).expect("parse");
855 let exact = document.exact().expect("exact");
856 let captured = captured_legacy_inline_router(exact).expect("inline router");
857
858 assert!(captured.legacy_inline);
859 assert_eq!(captured.origin, LEGACY_INLINE_ROUTER_ORIGIN);
860 assert_eq!(captured.service_kind, REASONING_ROUTER_SERVICE_KIND);
861 assert_eq!(captured.route.model, "glm-5-turbo");
862 assert_eq!(captured.requested_call_reasoning, RouterCallReasoning::Off);
863 assert!(!captured.is_dispatchable());
864 assert!(!captured.permissions.tools);
865
866 let snapshot = FleetSnapshot::capture(
867 id(),
868 &document,
869 "2026-07-26T00:00:00Z",
870 Some(captured.clone()),
871 )
872 .expect("capture");
873 assert_eq!(snapshot.router(), Some(&captured));
874 // The inline member is not in the roster.
875 assert!(snapshot.member("router").is_none());
876 assert_eq!(snapshot.members().len(), 1);
877 }
878
879 #[test]
880 fn editing_the_saved_fleet_does_not_touch_a_running_snapshot() {
881 let snapshot = capture(EXACT, Some(luna()));
882
883 // The operator edits the saved file mid-run: different model and
884 // reasoning. (The historic permissions key remains ignored input.)
885 let edited = EXACT
886 .replace(
887 "model = \"glm-5\"\nreasoning = \"auto\"",
888 "model = \"glm-4\"\nreasoning = \"off\"",
889 )
890 .replace("permissions = \"read_write\"", "permissions = \"full\"");
891 let next = capture(&edited, Some(luna()));
892
893 // The in-flight snapshot is untouched.
894 let member = snapshot.member("implementer").expect("member");
895 assert_eq!(member.route.model, "glm-5");
896 assert_eq!(member.requested_reasoning, RequestedReasoning::Auto);
897
898 // The next run sees the edit, and the hashes prove they differ.
899 assert_eq!(next.member("implementer").unwrap().route.model, "glm-4");
900 assert_ne!(snapshot.schema_hash(), next.schema_hash());
901 assert_ne!(snapshot.content_hash(), next.content_hash());
902 }
903
904 #[test]
905 fn identical_definitions_produce_an_identical_content_hash() {
906 let document = FleetDocument::parse(EXACT).expect("parse");
907 let a = FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", Some(luna()))
908 .expect("capture");
909 // Different capture time, same fleet: the content hash must not move.
910 let b = FleetSnapshot::capture(id(), &document, "2026-07-27T09:30:00Z", Some(luna()))
911 .expect("capture");
912
913 assert_eq!(a.content_hash(), b.content_hash());
914 assert_ne!(a.captured_at(), b.captured_at());
915 }
916
917 /// Swapping the attached Router is a real change to what will run, so it
918 /// must move the content hash.
919 #[test]
920 fn changing_the_attached_router_changes_the_content_hash() {
921 let with_luna = capture(EXACT, Some(luna()));
922 let without = capture(EXACT, None);
923 assert_ne!(with_luna.content_hash(), without.content_hash());
924 }
925
926 #[test]
927 fn legacy_role_map_fleets_snapshot_as_legacy() {
928 let document = FleetDocument::parse(LEGACY_ROLE_MAP).expect("parse legacy");
929 let snapshot = FleetSnapshot::capture(
930 QualifiedFleetId {
931 name: "stopship".to_string(),
932 origin: "workspace".to_string(),
933 },
934 &document,
935 "2026-07-26T00:00:00Z",
936 None,
937 )
938 .expect("capture");
939
940 assert_eq!(snapshot.schema_kind(), "legacy");
941 assert_eq!(snapshot.schema_revision(), 0);
942 assert!(snapshot.members().is_empty());
943 assert!(snapshot.router().is_none());
944 assert!(!snapshot.has_auto_member());
945 assert_eq!(snapshot.legacy_roles().len(), 2);
946 assert!(
947 snapshot
948 .legacy_roles()
949 .iter()
950 .any(|role| role.role == "implementer" && role.profile == "builder")
951 );
952 }
953
954 #[test]
955 fn snapshot_serialization_carries_no_secret_shaped_fields() {
956 let snapshot = capture(EXACT, Some(luna()));
957 let json = serde_json::to_string(&snapshot).expect("serialize");
958 let lowered = json.to_ascii_lowercase();
959
960 for forbidden in [
961 "api_key",
962 "apikey",
963 "secret",
964 "token",
965 "bearer",
966 "password",
967 "base_url",
968 "credential",
969 "authorization",
970 ] {
971 assert!(
972 !lowered.contains(forbidden),
973 "snapshot must not carry `{forbidden}`: {json}"
974 );
975 }
976
977 // Round-trips as a durable value.
978 let back: FleetSnapshot = serde_json::from_str(&json).expect("deserialize");
979 assert_eq!(back, snapshot);
980 }
981
982 /// A durable snapshot identifies its definition by qualified origin/name
983 /// and by hash — never by a filesystem path, which would leak the
984 /// operator's home directory and username into anything that stores it.
985 #[test]
986 fn a_snapshot_carries_no_filesystem_path() {
987 let tmp = tempfile::tempdir().expect("tmp");
988 std::fs::create_dir_all(tmp.path().join("fleets")).expect("dirs");
989 let path = tmp.path().join("fleets/glm-pair.toml");
990 std::fs::write(&path, EXACT).expect("write");
991 let document = FleetDocument::load(&path, Some("glm-pair")).expect("load from disk");
992 // The document still knows where it came from, for local diagnostics.
993 assert!(document.source_path().is_some());
994
995 let snapshot =
996 FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", Some(luna()))
997 .expect("capture");
998 let json = serde_json::to_string(&snapshot).expect("serialize");
999
1000 assert!(!json.contains(&tmp.path().display().to_string()), "{json}");
1001 for fragment in ["/Users/", "/home/", "/private/", ".toml", "\\Users\\"] {
1002 assert!(
1003 !json.contains(fragment),
1004 "snapshot must not carry `{fragment}`: {json}"
1005 );
1006 }
1007 assert_eq!(snapshot.fleet().qualified(), "workspace/glm-pair");
1008 assert!(snapshot.content_hash().starts_with("sha256:"));
1009 }
1010
1011 /// Capture is the last gate before a roster becomes a running Workflow, so
1012 /// a value that never saw the TOML parser must still be rejected here.
1013 #[test]
1014 fn capture_revalidates_a_roster_that_bypassed_the_parser() {
1015 use crate::fleet_exact::ExactMember;
1016
1017 let member = |id: &str, role: &str| ExactMember {
1018 id: id.to_string(),
1019 role: role.to_string(),
1020 provider: "zai".to_string(),
1021 model: "glm-5".to_string(),
1022 reasoning: RequestedReasoning::Off,
1023 };
1024 let smuggled = ExactFleet {
1025 name: "f".to_string(),
1026 description: None,
1027 schema_revision: 1,
1028 reasoning_router: None,
1029 // Two members, one role: role lookup would resolve by list order.
1030 members: vec![member("a", "builder"), member("b", "builder")],
1031 router: None,
1032 };
1033
1034 let document = FleetDocument::from_exact_for_tests(smuggled);
1035 let err = FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", None)
1036 .expect_err("capture must revalidate");
1037 assert!(
1038 matches!(err, ExactFleetError::DuplicateRole { .. }),
1039 "{err:?}"
1040 );
1041 }
1042 }
1043
1043 lines RUST