Skip to main content

codewhale_workflow/
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
14use serde::{Deserialize, Serialize};
15
16use crate::fleet_exact::{
17    ExactFleet, ExactFleetError, FrozenRoute, PermissionCeiling, RequestedReasoning,
18    canonical_member_key, canonical_role_key,
19};
20use crate::named_fleet::{FleetDocument, FleetSchema};
21use crate::reasoning_router::CapturedReasoningRouter;
22
23/// 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)]
33pub 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
40impl 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)]
50pub 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.
66pub type FleetSnapshotRouter = CapturedReasoningRouter;
67
68/// A legacy fleet's role → profile binding, recorded for provenance.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub 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)]
80pub 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
99impl 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
313fn exact_members(exact: &ExactFleet) -> Vec<FleetSnapshotMember> {
314    exact
315        .members
316        .iter()
317        .map(|member| FleetSnapshotMember {
318            id: canonical_member_key(&member.id),
319            // 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.
339pub 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]
351pub fn captured_legacy_inline_router(exact: &ExactFleet) -> Option<CapturedReasoningRouter> {
352    exact
353        .legacy_inline_router()
354        .map(CapturedReasoningRouter::from_legacy_inline)
355}
356
357#[cfg(test)]
358mod content_hash_tests {
359    use super::*;
360
361    const EXACT_FLEET: &str = r#"
362name = "glm-pair"
363schema = "exact"
364
365[[members]]
366id = "implementer"
367role = "builder"
368provider = "zai"
369model = "glm-5"
370reasoning = "high"
371permissions = "read_write"
372
373[[members]]
374id = "advisor-one"
375role = "oracle"
376provider = "zai"
377model = "glm-5"
378reasoning = "low"
379permissions = "analyst"
380"#;
381
382    fn captured() -> FleetSnapshot {
383        let document = FleetDocument::parse(EXACT_FLEET).expect("parse fleet document");
384        FleetSnapshot::capture(
385            QualifiedFleetId {
386                name: "glm-pair".to_string(),
387                origin: "workspace".to_string(),
388            },
389            &document,
390            "2026-07-26T00:00:00Z",
391            None,
392        )
393        .expect("capture")
394    }
395
396    #[test]
397    fn a_freshly_captured_snapshot_verifies() {
398        let snapshot = captured();
399        assert!(snapshot.verify_content_hash().is_ok());
400        assert!(verify_snapshot_content_hash(&snapshot).is_ok());
401        assert!(snapshot.into_verified().is_ok());
402    }
403
404    /// 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)]
514mod tests {
515    use super::*;
516    use crate::fleet_exact::ShellCeiling;
517    use crate::reasoning_router::{
518        LEGACY_INLINE_ROUTER_ORIGIN, REASONING_ROUTER_SERVICE_KIND, ReasoningRouterProfile,
519        RouterCallReasoning,
520    };
521
522    /// A Fleet that references a saved, reusable Router profile — the shape new
523    /// Fleets use.
524    const EXACT: &str = r#"
525name = "glm-pair"
526schema = "exact"
527reasoning_router = "luna-low"
528
529[[members]]
530id = "implementer"
531role = "builder"
532provider = "zai"
533model = "glm-5"
534reasoning = "auto"
535permissions = "read_write"
536
537[[members]]
538id = "auditor"
539provider = "zai"
540model = "glm-5"
541reasoning = "high"
542permissions = "read_only"
543"#;
544
545    /// The prototype form, retained for compatibility.
546    const LEGACY_INLINE: &str = r#"
547name = "glm-pair"
548schema = "exact"
549
550[[members]]
551id = "implementer"
552role = "builder"
553provider = "zai"
554model = "glm-5"
555reasoning = "auto"
556permissions = "read_write"
557
558[[members]]
559id = "router"
560kind = "router"
561provider = "zai"
562model = "glm-5-turbo"
563"#;
564
565    const LEGACY_ROLE_MAP: &str = r#"
566name = "stopship"
567description = "legacy roster"
568
569[roles]
570scout = "scout"
571implementer = "builder"
572"#;
573
574    const LUNA: &str = r#"
575name = "luna-low"
576schema = "reasoning_router"
577provider = "openai"
578model = "gpt-5.6-luna"
579call_reasoning = "low"
580"#;
581
582    fn id() -> QualifiedFleetId {
583        QualifiedFleetId {
584            name: "glm-pair".to_string(),
585            origin: "workspace".to_string(),
586        }
587    }
588
589    fn luna() -> CapturedReasoningRouter {
590        let profile = ReasoningRouterProfile::parse(LUNA).expect("router profile");
591        CapturedReasoningRouter::from_profile(&profile, "workspace")
592    }
593
594    fn capture(text: &str, router: Option<CapturedReasoningRouter>) -> FleetSnapshot {
595        let document = FleetDocument::parse(text).expect("parse");
596        FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", router).expect("capture")
597    }
598
599    #[test]
600    fn snapshot_captures_identity_schema_routes_and_ceilings() {
601        let snapshot = capture(EXACT, Some(luna()));
602
603        assert_eq!(snapshot.fleet().qualified(), "workspace/glm-pair");
604        assert_eq!(snapshot.schema_kind(), "exact");
605        assert_eq!(snapshot.schema_revision(), 1);
606        assert!(snapshot.schema_hash().starts_with("sha256:"));
607        assert!(snapshot.content_hash().starts_with("sha256:"));
608
609        // 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}