Skip to main content

gonzalo_domain/fleet/
records.rs

1//! The configuration records: people, account bindings, role grants and
2//! channel configs. All four merge `Structured` under sync (ADR 0022).
3
4use super::keys::{self, FleetKeyError};
5use super::{
6    Authenticator, CHANNELS_COLLECTION, FLEET_NAMESPACE, FleetActor, FleetRole, GrantScope,
7    IDENTITY_BINDINGS_COLLECTION, PEOPLE_COLLECTION, ROLE_GRANTS_COLLECTION,
8};
9use crate::codec::RecordCodec;
10use gonzalo_core::{RecordKey, RecordKind};
11use serde::{Deserialize, Deserializer, Serialize};
12use std::collections::BTreeSet;
13use std::fmt;
14
15/// A human with fleet roles, keyed by an opaque person id the minting
16/// consumer generates.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Person {
19    /// The one name shown for this person across surfaces.
20    pub display_name: String,
21    /// Operator-set contact address. Informational: never used to match an
22    /// account to a person.
23    pub email: Option<String>,
24}
25impl RecordCodec for Person {}
26impl Person {
27    pub const KIND: RecordKind = RecordKind::Person;
28
29    /// `fleet/people/<person_id>`.
30    pub fn key(person_id: &str) -> Result<RecordKey, FleetKeyError> {
31        keys::validate_person_id(person_id)?;
32        Ok(RecordKey::new(
33            FLEET_NAMESPACE,
34            PEOPLE_COLLECTION,
35            person_id,
36        ))
37    }
38}
39
40/// An email address as asserted by an authenticator.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct VerifiedEmail {
43    pub address: String,
44    pub verified: bool,
45}
46
47/// How an account came to be bound to a person.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub enum BindingOrigin {
50    /// Redeemed the link token with this hash.
51    LinkToken { token_hash: String },
52    /// Bound directly by an operator.
53    Operator(FleetActor),
54}
55
56/// Ties one external account to a person.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct IdentityBinding {
59    /// Stored as a one-element array; merges atomically (ADR 0022 §"Merge
60    /// classes in sync terms") so a concurrent change doesn't mix variant
61    /// tags with `bound_by`'s other variant.
62    #[serde(with = "super::atomic")]
63    pub authenticator: Authenticator,
64    /// The platform's stable user id, or the OIDC `sub`. The only lookup key.
65    pub subject: String,
66    /// The person id this account belongs to.
67    pub person: String,
68    /// A snapshot of the platform username, for display only.
69    pub handle: Option<String>,
70    /// Stored as a one-element array; merges atomically so a concurrent
71    /// change can't pair one side's `address` with the other's `verified`.
72    #[serde(with = "super::atomic")]
73    pub email: Option<VerifiedEmail>,
74    pub bound_at: i64,
75    /// Stored as a one-element array; merges atomically, since this is an
76    /// externally-tagged enum and object-recursive merging of two different
77    /// variants would produce an undecodable body.
78    #[serde(with = "super::atomic")]
79    pub bound_by: BindingOrigin,
80}
81impl RecordCodec for IdentityBinding {}
82impl IdentityBinding {
83    pub const KIND: RecordKind = RecordKind::IdentityBinding;
84
85    /// `fleet/identity-bindings/<authenticator>:<subject>`.
86    pub fn key_for(
87        authenticator: &Authenticator,
88        subject: &str,
89    ) -> Result<RecordKey, FleetKeyError> {
90        Ok(RecordKey::new(
91            FLEET_NAMESPACE,
92            IDENTITY_BINDINGS_COLLECTION,
93            keys::binding_id(authenticator, subject)?,
94        ))
95    }
96
97    pub fn key(&self) -> Result<RecordKey, FleetKeyError> {
98        Self::key_for(&self.authenticator, &self.subject)
99    }
100}
101
102/// A person's role for one scope. One record per `(person, scope)`; revoking
103/// is a delete.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct RoleGrant {
106    pub person: String,
107    /// Stored as a one-element array; merges atomically, matching `role`'s
108    /// own conflict-on-divergence behaviour (ADR 0022).
109    #[serde(with = "super::atomic")]
110    pub scope: GrantScope,
111    pub role: FleetRole,
112    /// Stored as a one-element array; merges atomically, since this is an
113    /// externally-tagged enum and object-recursive merging of two different
114    /// variants would produce an undecodable body.
115    #[serde(with = "super::atomic")]
116    pub granted_by: FleetActor,
117    pub granted_at: i64,
118}
119impl RecordCodec for RoleGrant {}
120impl RoleGrant {
121    pub const KIND: RecordKind = RecordKind::RoleGrant;
122
123    /// `fleet/role-grants/<person_id>:<scope>`.
124    pub fn key_for(person: &str, scope: &GrantScope) -> Result<RecordKey, FleetKeyError> {
125        Ok(RecordKey::new(
126            FLEET_NAMESPACE,
127            ROLE_GRANTS_COLLECTION,
128            keys::grant_id(person, scope)?,
129        ))
130    }
131
132    pub fn key(&self) -> Result<RecordKey, FleetKeyError> {
133        Self::key_for(&self.person, &self.scope)
134    }
135}
136
137/// Which workspaces a channel follows (ariel ADR 0009; gonzalo ADR 0023).
138///
139/// Stored tagged: `{"kind":"fleet"}` or
140/// `{"kind":"workspaces","names":[…]}` with at least one name.
141#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
142#[serde(tag = "kind", rename_all = "snake_case")]
143pub enum Follows {
144    /// Every workspace, including ones created later.
145    Fleet,
146    /// A non-empty set of workspace names, as prospero reports them. Build it
147    /// with [`Follows::workspaces`], which rejects an empty set.
148    Workspaces { names: BTreeSet<String> },
149}
150
151impl Follows {
152    /// `Workspaces` over a non-empty set of names.
153    pub fn workspaces<I, S>(names: I) -> Result<Self, EmptyFollowSet>
154    where
155        I: IntoIterator<Item = S>,
156        S: Into<String>,
157    {
158        let names: BTreeSet<String> = names.into_iter().map(Into::into).collect();
159        if names.is_empty() {
160            return Err(EmptyFollowSet);
161        }
162        Ok(Follows::Workspaces { names })
163    }
164}
165
166impl<'de> Deserialize<'de> for Follows {
167    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
168        // A shadow type carries the wire shape; the invariant (a non-empty
169        // name set) is enforced here so no decoded `Follows` can break it.
170        #[derive(Deserialize)]
171        #[serde(tag = "kind", rename_all = "snake_case")]
172        enum Wire {
173            Fleet,
174            Workspaces { names: BTreeSet<String> },
175        }
176        match Wire::deserialize(deserializer)? {
177            Wire::Fleet => Ok(Follows::Fleet),
178            Wire::Workspaces { names } if names.is_empty() => {
179                Err(serde::de::Error::custom(EmptyFollowSet))
180            }
181            Wire::Workspaces { names } => Ok(Follows::Workspaces { names }),
182        }
183    }
184}
185
186/// `Follows::Workspaces` was given no workspace names.
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub struct EmptyFollowSet;
189
190impl fmt::Display for EmptyFollowSet {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        f.write_str("a channel following workspaces must name at least one")
193    }
194}
195
196impl std::error::Error for EmptyFollowSet {}
197
198/// How much a channel hears. A preset only narrows the consumer's default
199/// pacing; it can never add event kinds (ariel ADR 0007/0009).
200#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub enum NotifyPreset {
203    /// Everything the consumer's pacing allows.
204    #[default]
205    All,
206    /// Only when an agent ends.
207    Terminal,
208    /// Only when an agent ends failed, crashed or gone.
209    Failures,
210}
211
212/// Per-chat-channel configuration, keyed by provider, tenant and channel
213/// (ariel ADR 0009; gonzalo ADR 0023). It belongs to no person: who changed it
214/// is recorded in the audit trail and in gonzalo's author stamp.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ChannelConfig {
217    /// The chat provider, such as `"discord"`.
218    pub provider: String,
219    /// The guild, workspace or team id the channel belongs to. Part of the key,
220    /// because a channel id is only unique within its tenant on some platforms.
221    pub tenant: String,
222    /// The platform's channel id.
223    pub channel: String,
224    /// Stored as a one-element array; merges atomically (ADR 0022), so two
225    /// concurrent follow changes conflict instead of one silently winning.
226    #[serde(with = "super::atomic")]
227    pub follows: Follows,
228    /// Defaults to [`NotifyPreset::All`].
229    #[serde(default)]
230    pub notify: NotifyPreset,
231    /// The highest role any command in this channel runs with. Defaults to
232    /// [`FleetRole::Viewer`]; it governs commands only, never notifications.
233    #[serde(default)]
234    pub ceiling: FleetRole,
235}
236impl RecordCodec for ChannelConfig {}
237impl ChannelConfig {
238    pub const KIND: RecordKind = RecordKind::ChannelConfig;
239
240    /// `fleet/channels/<provider>:<tenant>:<channel>`.
241    pub fn key_for(
242        provider: &str,
243        tenant: &str,
244        channel: &str,
245    ) -> Result<RecordKey, FleetKeyError> {
246        Ok(RecordKey::new(
247            FLEET_NAMESPACE,
248            CHANNELS_COLLECTION,
249            keys::channel_id(provider, tenant, channel)?,
250        ))
251    }
252
253    pub fn key(&self) -> Result<RecordKey, FleetKeyError> {
254        Self::key_for(&self.provider, &self.tenant, &self.channel)
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn person_roundtrips_and_keys() {
264        let p = Person {
265            display_name: "Ada".into(),
266            email: Some("ada@example.com".into()),
267        };
268        assert_eq!(Person::from_body(&p.to_body().unwrap()).unwrap(), p);
269        assert_eq!(Person::KIND, RecordKind::Person);
270        assert_eq!(
271            Person::key("p1").unwrap(),
272            RecordKey::new("fleet", "people", "p1")
273        );
274        assert!(Person::key("ada@example.com").is_err());
275    }
276
277    #[test]
278    fn identity_binding_roundtrips_and_keys() {
279        let b = IdentityBinding {
280            authenticator: Authenticator::Oidc {
281                issuer: "https://id.example.com".into(),
282            },
283            subject: "sub-1".into(),
284            person: "p1".into(),
285            handle: Some("ada".into()),
286            email: Some(VerifiedEmail {
287                address: "ada@example.com".into(),
288                verified: true,
289            }),
290            bound_at: 1_700_000_000_000,
291            bound_by: BindingOrigin::Operator(FleetActor::Service("ariel-cli".into())),
292        };
293        assert_eq!(
294            IdentityBinding::from_body(&b.to_body().unwrap()).unwrap(),
295            b
296        );
297        assert_eq!(IdentityBinding::KIND, RecordKind::IdentityBinding);
298        assert_eq!(
299            b.key().unwrap(),
300            RecordKey::new(
301                "fleet",
302                "identity-bindings",
303                "oidc:https%3A//id.example.com:sub-1"
304            )
305        );
306    }
307
308    #[test]
309    fn role_grant_roundtrips_and_keys() {
310        let g = RoleGrant {
311            person: "p1".into(),
312            scope: GrantScope::Workspace("caliban".into()),
313            role: FleetRole::Operator,
314            granted_by: FleetActor::Person("p0".into()),
315            granted_at: 1_700_000_000_000,
316        };
317        assert_eq!(RoleGrant::from_body(&g.to_body().unwrap()).unwrap(), g);
318        assert_eq!(RoleGrant::KIND, RecordKind::RoleGrant);
319        assert_eq!(
320            g.key().unwrap(),
321            RecordKey::new("fleet", "role-grants", "p1:workspace:caliban")
322        );
323    }
324
325    fn ops_channel() -> ChannelConfig {
326        // ariel ADR 0009's `#ops` example: the whole fleet, failures only,
327        // read-only commands.
328        ChannelConfig {
329            provider: "discord".into(),
330            tenant: "guild-1".into(),
331            channel: "42".into(),
332            follows: Follows::Fleet,
333            notify: NotifyPreset::Failures,
334            ceiling: FleetRole::Viewer,
335        }
336    }
337
338    #[test]
339    fn channel_config_roundtrips_and_keys() {
340        let c = ops_channel();
341        assert_eq!(ChannelConfig::from_body(&c.to_body().unwrap()).unwrap(), c);
342        assert_eq!(ChannelConfig::KIND, RecordKind::ChannelConfig);
343        assert_eq!(
344            c.key().unwrap(),
345            RecordKey::new("fleet", "channels", "discord:guild-1:42")
346        );
347    }
348
349    #[test]
350    fn channel_config_keys_include_the_tenant() {
351        // The same channel id under two tenants is two records, not one
352        // (ariel ADR 0006).
353        let a = ops_channel();
354        let mut b = ops_channel();
355        b.tenant = "guild-2".into();
356        assert_ne!(a.key().unwrap(), b.key().unwrap());
357    }
358
359    #[test]
360    fn follows_stores_ariel_adr_0009_wire_shape() {
361        // ariel ADR 0009: {"kind":"fleet"} or
362        // {"kind":"workspaces","names":[…]}, inside gonzalo's atomic wrapper.
363        let mut c = ops_channel();
364        c.follows = Follows::workspaces(["caliban"]).unwrap();
365        let body = c.to_body().unwrap();
366        let v: serde_json::Value = serde_json::from_slice(body.bytes()).unwrap();
367        assert_eq!(
368            v["follows"],
369            serde_json::json!([{"kind": "workspaces", "names": ["caliban"]}])
370        );
371        assert_eq!(v["notify"], serde_json::json!("failures"));
372
373        let fleet_body = ops_channel().to_body().unwrap();
374        let v: serde_json::Value = serde_json::from_slice(fleet_body.bytes()).unwrap();
375        assert_eq!(v["follows"], serde_json::json!([{"kind": "fleet"}]));
376        assert_eq!(ChannelConfig::from_body(&body).unwrap(), c);
377    }
378
379    #[test]
380    fn follows_workspaces_must_name_at_least_one() {
381        assert_eq!(
382            Follows::workspaces(Vec::<String>::new()),
383            Err(EmptyFollowSet)
384        );
385        assert!(Follows::workspaces(["caliban", "gonzalo"]).is_ok());
386
387        // The invariant also holds for a body written by hand.
388        let mut v: serde_json::Value =
389            serde_json::from_slice(ops_channel().to_body().unwrap().bytes()).unwrap();
390        v["follows"] = serde_json::json!([{"kind": "workspaces", "names": []}]);
391        let bytes = serde_json::to_vec(&v).unwrap();
392        assert!(
393            ChannelConfig::from_body(&gonzalo_core::Body::Inline(bytes)).is_err(),
394            "an empty workspace set must be rejected"
395        );
396    }
397
398    #[test]
399    fn channel_config_defaults_notify_all_and_ceiling_viewer() {
400        // `follows` is required; the other two are optional (ariel ADR 0009).
401        let json = serde_json::json!({
402            "provider": "discord",
403            "tenant": "guild-1",
404            "channel": "42",
405            "follows": [{"kind": "fleet"}],
406        });
407        let bytes = serde_json::to_vec(&json).unwrap();
408        let c = ChannelConfig::from_body(&gonzalo_core::Body::Inline(bytes)).unwrap();
409        assert_eq!(c.notify, NotifyPreset::All);
410        assert_eq!(c.ceiling, FleetRole::Viewer);
411
412        let missing_follows = serde_json::json!({
413            "provider": "discord",
414            "tenant": "guild-1",
415            "channel": "42",
416        });
417        let bytes = serde_json::to_vec(&missing_follows).unwrap();
418        assert!(
419            ChannelConfig::from_body(&gonzalo_core::Body::Inline(bytes)).is_err(),
420            "follows is required"
421        );
422    }
423
424    // ---- atomic wrapper: shape and error handling (ADR 0022) ----
425
426    #[test]
427    fn atomic_wrapped_field_stores_as_one_element_array() {
428        let g = RoleGrant {
429            person: "p1".into(),
430            scope: GrantScope::Fleet,
431            role: FleetRole::Operator,
432            granted_by: FleetActor::Person("p0".into()),
433            granted_at: 1_700_000_000_000,
434        };
435        let body = g.to_body().unwrap();
436        let text = String::from_utf8(body.bytes().to_vec()).unwrap();
437        assert!(
438            text.contains(r#""granted_by":[{"#),
439            "granted_by must be stored as a one-element array, got: {text}"
440        );
441        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
442        assert!(v["granted_by"].as_array().unwrap().len() == 1);
443        // Round-trips.
444        assert_eq!(RoleGrant::from_body(&body).unwrap(), g);
445    }
446
447    #[test]
448    fn atomic_wrapped_field_rejects_empty_and_multi_element_arrays() {
449        let g = RoleGrant {
450            person: "p1".into(),
451            scope: GrantScope::Fleet,
452            role: FleetRole::Operator,
453            granted_by: FleetActor::Person("p0".into()),
454            granted_at: 1_700_000_000_000,
455        };
456        let mut v: serde_json::Value =
457            serde_json::from_slice(g.to_body().unwrap().bytes()).unwrap();
458
459        v["granted_by"] = serde_json::json!([]);
460        let bytes = serde_json::to_vec(&v).unwrap();
461        assert!(
462            RoleGrant::from_body(&gonzalo_core::Body::Inline(bytes)).is_err(),
463            "an empty array must be rejected"
464        );
465
466        v["granted_by"] = serde_json::json!([{"Person": "p0"}, {"Person": "p0"}]);
467        let bytes = serde_json::to_vec(&v).unwrap();
468        assert!(
469            RoleGrant::from_body(&gonzalo_core::Body::Inline(bytes)).is_err(),
470            "a two-element array must be rejected"
471        );
472    }
473
474    // ---- merge-level tests: composite fields merge atomically (ADR 0022) ----
475
476    fn merge_bodies(
477        kind: RecordKind,
478        base: &gonzalo_core::Body,
479        ours: &gonzalo_core::Body,
480        theirs: &gonzalo_core::Body,
481    ) -> gonzalo_core::MergeOutcome {
482        gonzalo_core::merge(kind.merge_class(), base, ours, theirs)
483    }
484
485    #[test]
486    fn role_grant_variant_mix_on_granted_by_needs_resolution() {
487        // Base: Person; ours: Service; theirs: Unlinked. Without the atomic
488        // wrapper, core's object-recursive Structured merge would delete
489        // "Person" on both sides and add "Service" and "Unlinked", producing
490        // a two-tag body that fails to decode. Wrapped as an array, this is a
491        // genuine conflict instead.
492        let base = RoleGrant {
493            person: "p1".into(),
494            scope: GrantScope::Fleet,
495            role: FleetRole::Operator,
496            granted_by: FleetActor::Person("p0".into()),
497            granted_at: 1_700_000_000_000,
498        };
499        let mut ours = base.clone();
500        ours.granted_by = FleetActor::Service("ariel".into());
501        let mut theirs = base.clone();
502        theirs.granted_by = FleetActor::Unlinked {
503            authenticator: Authenticator::Discord,
504            subject: "1234".into(),
505        };
506
507        let outcome = merge_bodies(
508            RecordKind::RoleGrant,
509            &base.to_body().unwrap(),
510            &ours.to_body().unwrap(),
511            &theirs.to_body().unwrap(),
512        );
513        assert_eq!(outcome, gonzalo_core::MergeOutcome::NeedsResolution);
514    }
515
516    #[test]
517    fn identity_binding_email_half_mix_needs_resolution() {
518        // Base: unverified a@x. Ours changes only the address; theirs verifies
519        // it. Without the atomic wrapper, merging the object key-by-key would
520        // combine ours' `address` with theirs' `verified`, asserting a
521        // verification nobody made for that address.
522        let base = IdentityBinding {
523            authenticator: Authenticator::Discord,
524            subject: "sub-1".into(),
525            person: "p1".into(),
526            handle: None,
527            email: Some(VerifiedEmail {
528                address: "a@x".into(),
529                verified: false,
530            }),
531            bound_at: 1_700_000_000_000,
532            bound_by: BindingOrigin::Operator(FleetActor::Service("ariel".into())),
533        };
534        let mut ours = base.clone();
535        ours.email = Some(VerifiedEmail {
536            address: "b@x".into(),
537            verified: false,
538        });
539        let mut theirs = base.clone();
540        theirs.email = Some(VerifiedEmail {
541            address: "a@x".into(),
542            verified: true,
543        });
544
545        let outcome = merge_bodies(
546            RecordKind::IdentityBinding,
547            &base.to_body().unwrap(),
548            &ours.to_body().unwrap(),
549            &theirs.to_body().unwrap(),
550        );
551        assert_eq!(outcome, gonzalo_core::MergeOutcome::NeedsResolution);
552    }
553
554    #[test]
555    fn identity_binding_disjoint_atomic_fields_merge() {
556        // Ours changes only `handle`; theirs changes only `email`. Disjoint
557        // field edits still merge even though both fields are atomically
558        // wrapped.
559        let base = IdentityBinding {
560            authenticator: Authenticator::Discord,
561            subject: "sub-1".into(),
562            person: "p1".into(),
563            handle: None,
564            email: None,
565            bound_at: 1_700_000_000_000,
566            bound_by: BindingOrigin::Operator(FleetActor::Service("ariel".into())),
567        };
568        let mut ours = base.clone();
569        ours.handle = Some("ada".into());
570        let mut theirs = base.clone();
571        theirs.email = Some(VerifiedEmail {
572            address: "a@x".into(),
573            verified: true,
574        });
575
576        let outcome = merge_bodies(
577            RecordKind::IdentityBinding,
578            &base.to_body().unwrap(),
579            &ours.to_body().unwrap(),
580            &theirs.to_body().unwrap(),
581        );
582        let gonzalo_core::MergeOutcome::Merged(body) = outcome else {
583            panic!("expected a merge, got NeedsResolution");
584        };
585        let merged = IdentityBinding::from_body(&body).unwrap();
586        assert_eq!(merged.handle, Some("ada".into()));
587        assert_eq!(
588            merged.email,
589            Some(VerifiedEmail {
590                address: "a@x".into(),
591                verified: true,
592            })
593        );
594    }
595
596    #[test]
597    fn role_grant_one_sided_atomic_change_merges() {
598        // Only `ours` changed `granted_by`; theirs is unchanged from base.
599        let base = RoleGrant {
600            person: "p1".into(),
601            scope: GrantScope::Fleet,
602            role: FleetRole::Operator,
603            granted_by: FleetActor::Person("p0".into()),
604            granted_at: 1_700_000_000_000,
605        };
606        let mut ours = base.clone();
607        ours.granted_by = FleetActor::Service("ariel".into());
608        let theirs = base.clone();
609
610        let outcome = merge_bodies(
611            RecordKind::RoleGrant,
612            &base.to_body().unwrap(),
613            &ours.to_body().unwrap(),
614            &theirs.to_body().unwrap(),
615        );
616        let gonzalo_core::MergeOutcome::Merged(body) = outcome else {
617            panic!("expected a merge, got NeedsResolution");
618        };
619        let merged = RoleGrant::from_body(&body).unwrap();
620        assert_eq!(merged.granted_by, FleetActor::Service("ariel".into()));
621    }
622
623    #[test]
624    fn channel_follows_changed_on_both_sides_needs_resolution() {
625        // Two concurrent follow edits conflict rather than one silently
626        // winning or the two sets being spliced together (ADR 0023).
627        let base = ops_channel();
628        let mut ours = base.clone();
629        ours.follows = Follows::workspaces(["caliban"]).unwrap();
630        let mut theirs = base.clone();
631        theirs.follows = Follows::workspaces(["gonzalo"]).unwrap();
632
633        let outcome = merge_bodies(
634            RecordKind::ChannelConfig,
635            &base.to_body().unwrap(),
636            &ours.to_body().unwrap(),
637            &theirs.to_body().unwrap(),
638        );
639        assert_eq!(outcome, gonzalo_core::MergeOutcome::NeedsResolution);
640    }
641
642    #[test]
643    fn channel_disjoint_notify_and_follows_edits_merge() {
644        // Ours retargets the channel; theirs only widens what it hears.
645        let base = ops_channel();
646        let mut ours = base.clone();
647        ours.follows = Follows::workspaces(["caliban"]).unwrap();
648        let mut theirs = base.clone();
649        theirs.notify = NotifyPreset::All;
650
651        let outcome = merge_bodies(
652            RecordKind::ChannelConfig,
653            &base.to_body().unwrap(),
654            &ours.to_body().unwrap(),
655            &theirs.to_body().unwrap(),
656        );
657        let gonzalo_core::MergeOutcome::Merged(body) = outcome else {
658            panic!("expected a merge, got NeedsResolution");
659        };
660        let merged = ChannelConfig::from_body(&body).unwrap();
661        assert_eq!(merged.follows, Follows::workspaces(["caliban"]).unwrap());
662        assert_eq!(merged.notify, NotifyPreset::All);
663    }
664}