@@ -236,13 +236,13 @@ impl std::error::Error for CascadeError {}
236236/// reference to the alias remains. `DryRun` computes the plan and mutates
237237/// nothing. [`plan_delete`] is the read-only sibling.
238238///
239- /// Implements the **model-provider** kind (`providers.models.<family>.<alias>`)
240- /// and the **agent** kind (`agents.<alias>`). The agent arm cascades config
241- /// references only; its owned non-config state (memory rows, workspace dir,
242- /// cron/acp/session rows) is cascaded by the calling surface and is not yet
243- /// reflected in `ImpactReport.owned_state`. TTS/transcription providers and
244- /// channels return [`CascadeError::NotImplemented`] until their follow-up lands
245- /// (#7175).
239+ /// Implements the **model-provider** (`providers.models.<family>.<alias>`),
240+ /// **agent** (`agents.<alias>`), and **channel** (`channels.<type>.<alias>`)
241+ /// kinds. The agent arm cascades config references only; its owned non-config
242+ /// state (memory rows, workspace dir, cron/acp/session rows) is cascaded by the
243+ /// calling surface and is not reflected in `ImpactReport.owned_state`.
244+ /// TTS/transcription providers return [`CascadeError::NotImplemented`] until
245+ /// their follow-up lands (#7175).
246246pub fn delete_with_cascade (
247247 cfg : & mut Config ,
248248 kind : & AliasKind ,
@@ -258,9 +258,7 @@ pub fn delete_with_cascade(
258258 "TTS/transcription provider delete-with-cascade is not yet implemented" . to_string ( ) ,
259259 ) ) ,
260260 AliasKind :: Agent => delete_agent ( cfg, alias, policy) ,
261- AliasKind :: Channel { .. } => Err ( CascadeError :: NotImplemented (
262- "channel delete-with-cascade lands in a follow-up (#7175)" . to_string ( ) ,
263- ) ) ,
261+ AliasKind :: Channel { channel_type } => delete_channel ( cfg, channel_type, alias, policy) ,
264262 }
265263}
266264
@@ -428,6 +426,81 @@ fn scrub_agent_refs(cfg: &mut Config, alias: &str) {
428426 }
429427}
430428
429+ fn delete_channel (
430+ cfg : & mut Config ,
431+ channel_type : & str ,
432+ alias : & str ,
433+ policy : CascadePolicy ,
434+ ) -> Result < CascadeReport , CascadeError > {
435+ let entry_path = format ! ( "channels.{channel_type}.{alias}" ) ;
436+ let section = format ! ( "channels.{channel_type}" ) ;
437+ let exists = cfg
438+ . get_map_keys ( & section)
439+ . is_some_and ( |keys| keys. iter ( ) . any ( |k| k == alias) ) ;
440+ if !exists {
441+ return Err ( CascadeError :: NotFound ( entry_path) ) ;
442+ }
443+
444+ let kind = AliasKind :: Channel {
445+ channel_type : channel_type. to_string ( ) ,
446+ } ;
447+ let report = plan_delete ( cfg, & kind, alias) ;
448+
449+ if policy == CascadePolicy :: DryRun {
450+ return Ok ( CascadeReport {
451+ plan : report,
452+ applied : Vec :: new ( ) ,
453+ deleted_entry : None ,
454+ } ) ;
455+ }
456+ // HARD channel refs (see `collect_channel_refs`): a mandatory dotted
457+ // `peer_groups.<g>.channel`, or a bare-type group member whose only
458+ // `<type>.*` channel is the target (scrubbing it would orphan the member).
459+ if !report. allowed {
460+ return Err ( CascadeError :: Refused ( Box :: new ( report) ) ) ;
461+ }
462+
463+ let applied = report. scrubs . clone ( ) ;
464+ let target = format ! ( "{channel_type}.{alias}" ) ;
465+ scrub_channel_refs ( cfg, & target) ;
466+ // Remove the `channels.<type>.<alias>` entry via the same generic map-key
467+ // path the gateway/CLI use.
468+ if let Err ( e) = cfg. delete_map_key ( & section, alias) {
469+ return Err ( CascadeError :: PostCondition ( format ! (
470+ "failed to remove {entry_path}: {e}"
471+ ) ) ) ;
472+ }
473+
474+ let remaining = find_all_references ( cfg, & kind, alias) ;
475+ if !remaining. is_empty ( ) {
476+ let paths: Vec < _ > = remaining. iter ( ) . map ( |s| s. path . as_str ( ) ) . collect ( ) ;
477+ return Err ( CascadeError :: PostCondition ( format ! (
478+ "{} dangling reference(s) to {target} remain: {}" ,
479+ remaining. len( ) ,
480+ paths. join( ", " )
481+ ) ) ) ;
482+ }
483+
484+ Ok ( CascadeReport {
485+ plan : report,
486+ applied,
487+ deleted_entry : Some ( entry_path) ,
488+ } )
489+ }
490+
491+ /// Mutating mirror of [`collect_channel_refs`]: drop the soft channel references
492+ /// to `target` (`"<type>.<alias>"`). `peer_groups.<g>.channel` is a HARD ref and
493+ /// is never scrubbed (a delete carrying one is refused before reaching here).
494+ /// Comparisons `.trim()` to mirror `find_all_references` and `validate()`.
495+ fn scrub_channel_refs ( cfg : & mut Config , target : & str ) {
496+ for agent in cfg. agents . values_mut ( ) {
497+ agent. channels . retain ( |ch| ch. trim ( ) != target) ;
498+ }
499+ cfg. escalation
500+ . alert_channels
501+ . retain ( |ch| ch. trim ( ) != target) ;
502+ }
503+
431504// ── deterministic iteration over the alias-keyed maps ───────────────────────
432505// `Config::agents` / `peer_groups` are HashMaps; sort by key so RefSite order
433506// is stable across runs (tests + dashboard binding depend on it).
@@ -1444,26 +1517,19 @@ mod tests {
14441517
14451518 #[ test]
14461519 fn cascade_not_implemented_for_other_kinds ( ) {
1520+ // Only TTS/transcription providers remain unimplemented now (model
1521+ // providers, agents, and channels are all wired).
14471522 let mut cfg = empty_config ( ) ;
1448- assert ! ( matches!(
1449- delete_with_cascade(
1450- & mut cfg,
1451- & AliasKind :: Channel {
1452- channel_type: "discord" . to_string( )
1453- } ,
1454- "x" ,
1455- CascadePolicy :: RefuseOnHard ,
1456- ) ,
1457- Err ( CascadeError :: NotImplemented ( _) )
1458- ) ) ;
1459- let tts = AliasKind :: Provider {
1460- category : ProviderCategory :: Tts ,
1461- family : "elevenlabs" . to_string ( ) ,
1462- } ;
1463- assert ! ( matches!(
1464- delete_with_cascade( & mut cfg, & tts, "x" , CascadePolicy :: RefuseOnHard ) ,
1465- Err ( CascadeError :: NotImplemented ( _) )
1466- ) ) ;
1523+ for category in [ ProviderCategory :: Tts , ProviderCategory :: Transcription ] {
1524+ let kind = AliasKind :: Provider {
1525+ category,
1526+ family : "x" . to_string ( ) ,
1527+ } ;
1528+ assert ! ( matches!(
1529+ delete_with_cascade( & mut cfg, & kind, "x" , CascadePolicy :: RefuseOnHard ) ,
1530+ Err ( CascadeError :: NotImplemented ( _) )
1531+ ) ) ;
1532+ }
14671533 }
14681534
14691535 // ── delete_with_cascade (agents) ────────────────────────────────────────
@@ -1627,4 +1693,207 @@ mod tests {
16271693 assert ! ( !cfg. agents. contains_key( "bot" ) ) ;
16281694 assert ! ( find_all_references( & cfg, & AliasKind :: Agent , "bot" ) . is_empty( ) ) ;
16291695 }
1696+
1697+ // ── delete_with_cascade (channels) ──────────────────────────────────────
1698+
1699+ fn channel_kind ( ) -> AliasKind {
1700+ AliasKind :: Channel {
1701+ channel_type : "discord" . to_string ( ) ,
1702+ }
1703+ }
1704+
1705+ fn has_channel ( cfg : & Config , alias : & str ) -> bool {
1706+ cfg. get_map_keys ( "channels.discord" )
1707+ . unwrap_or_default ( )
1708+ . iter ( )
1709+ . any ( |k| k == alias)
1710+ }
1711+
1712+ #[ test]
1713+ fn cascade_channel_scrubs_soft_refs_and_removes_entry ( ) {
1714+ let mut cfg = empty_config ( ) ;
1715+ cfg. create_map_key ( "channels.discord" , "main" ) . unwrap ( ) ;
1716+ cfg. agents . insert (
1717+ "ops" . to_string ( ) ,
1718+ AliasedAgentConfig {
1719+ channels : vec ! [ "discord.main" . into( ) ] ,
1720+ ..Default :: default ( )
1721+ } ,
1722+ ) ;
1723+ cfg. escalation
1724+ . alert_channels
1725+ . push ( "discord.main" . to_string ( ) ) ;
1726+
1727+ let report = delete_with_cascade (
1728+ & mut cfg,
1729+ & channel_kind ( ) ,
1730+ "main" ,
1731+ CascadePolicy :: RefuseOnHard ,
1732+ )
1733+ . expect ( "soft-only channel delete succeeds" ) ;
1734+ assert_eq ! ( report. applied. len( ) , 2 , "agent channel + alert_channel" ) ;
1735+ assert_eq ! (
1736+ report. deleted_entry. as_deref( ) ,
1737+ Some ( "channels.discord.main" )
1738+ ) ;
1739+ assert ! ( !has_channel( & cfg, "main" ) ) ;
1740+ assert ! ( cfg. agents[ "ops" ] . channels. is_empty( ) ) ;
1741+ assert ! ( cfg. escalation. alert_channels. is_empty( ) ) ;
1742+ assert ! ( find_all_references( & cfg, & channel_kind( ) , "main" ) . is_empty( ) ) ;
1743+ }
1744+
1745+ #[ test]
1746+ fn cascade_channel_refuses_on_hard_peer_group_ref ( ) {
1747+ let mut cfg = empty_config ( ) ;
1748+ cfg. create_map_key ( "channels.discord" , "main" ) . unwrap ( ) ;
1749+ cfg. peer_groups . insert (
1750+ "crew" . to_string ( ) ,
1751+ PeerGroupConfig {
1752+ channel : "discord.main" . into ( ) ,
1753+ ..Default :: default ( )
1754+ } ,
1755+ ) ;
1756+ let err = delete_with_cascade (
1757+ & mut cfg,
1758+ & channel_kind ( ) ,
1759+ "main" ,
1760+ CascadePolicy :: RefuseOnHard ,
1761+ )
1762+ . unwrap_err ( ) ;
1763+ match err {
1764+ CascadeError :: Refused ( report) => {
1765+ assert_eq ! ( report. blockers[ 0 ] . path, "peer_groups.crew.channel" ) ;
1766+ }
1767+ other => panic ! ( "expected Refused, got {other:?}" ) ,
1768+ }
1769+ assert ! ( has_channel( & cfg, "main" ) , "no mutation on refuse" ) ;
1770+ }
1771+
1772+ #[ test]
1773+ fn cascade_channel_dry_run_mutates_nothing ( ) {
1774+ let mut cfg = empty_config ( ) ;
1775+ cfg. create_map_key ( "channels.discord" , "main" ) . unwrap ( ) ;
1776+ cfg. agents . insert (
1777+ "ops" . to_string ( ) ,
1778+ AliasedAgentConfig {
1779+ channels : vec ! [ "discord.main" . into( ) ] ,
1780+ ..Default :: default ( )
1781+ } ,
1782+ ) ;
1783+ let report =
1784+ delete_with_cascade ( & mut cfg, & channel_kind ( ) , "main" , CascadePolicy :: DryRun ) . unwrap ( ) ;
1785+ assert ! ( report. deleted_entry. is_none( ) ) ;
1786+ assert_eq ! ( report. plan. scrubs. len( ) , 1 ) ;
1787+ assert ! ( has_channel( & cfg, "main" ) ) ;
1788+ assert_eq ! ( cfg. agents[ "ops" ] . channels. len( ) , 1 ) ;
1789+ }
1790+
1791+ #[ test]
1792+ fn cascade_channel_not_found ( ) {
1793+ let mut cfg = empty_config ( ) ;
1794+ let err = delete_with_cascade (
1795+ & mut cfg,
1796+ & channel_kind ( ) ,
1797+ "ghost" ,
1798+ CascadePolicy :: RefuseOnHard ,
1799+ )
1800+ . unwrap_err ( ) ;
1801+ assert ! ( matches!( err, CascadeError :: NotFound ( _) ) ) ;
1802+ }
1803+
1804+ #[ test]
1805+ fn cascade_channel_refuses_orphaning_bare_group_member ( ) {
1806+ // BARE-type group ("discord", not "discord.main"). validate()
1807+ // (schema.rs:17461-17478) requires each member to keep some `discord.*`
1808+ // channel. `ops`'s only discord channel is the one being deleted, so the
1809+ // delete must REFUSE — scrubbing it would yield a config validate() rejects.
1810+ let mut cfg = empty_config ( ) ;
1811+ cfg. create_map_key ( "channels.discord" , "main" ) . unwrap ( ) ;
1812+ cfg. agents . insert (
1813+ "ops" . to_string ( ) ,
1814+ AliasedAgentConfig {
1815+ channels : vec ! [ "discord.main" . into( ) ] ,
1816+ ..Default :: default ( )
1817+ } ,
1818+ ) ;
1819+ let mut group = PeerGroupConfig {
1820+ channel : "discord" . into ( ) , // bare type
1821+ ..Default :: default ( )
1822+ } ;
1823+ group. agents . push ( AgentAlias :: new ( "ops" ) ) ;
1824+ cfg. peer_groups . insert ( "crew" . to_string ( ) , group) ;
1825+
1826+ let err = delete_with_cascade (
1827+ & mut cfg,
1828+ & channel_kind ( ) ,
1829+ "main" ,
1830+ CascadePolicy :: RefuseOnHard ,
1831+ )
1832+ . unwrap_err ( ) ;
1833+ match err {
1834+ CascadeError :: Refused ( report) => {
1835+ assert ! (
1836+ report
1837+ . blockers
1838+ . iter( )
1839+ . any( |b| b. path == "peer_groups.crew.agents[0]" ) ,
1840+ "bare-group member orphan must be a hard blocker, got {:?}" ,
1841+ report. blockers
1842+ ) ;
1843+ }
1844+ other => panic ! ( "expected Refused, got {other:?}" ) ,
1845+ }
1846+ assert ! ( has_channel( & cfg, "main" ) , "no mutation on refuse" ) ;
1847+ assert_eq ! (
1848+ cfg. agents[ "ops" ] . channels. len( ) ,
1849+ 1 ,
1850+ "member channel not scrubbed on refuse"
1851+ ) ;
1852+ }
1853+
1854+ #[ test]
1855+ fn cascade_channel_proceeds_when_bare_group_member_keeps_another ( ) {
1856+ // Same bare-type group, but `ops` also has `discord.backup`. Deleting
1857+ // `discord.main` leaves it with a surviving `discord.*`, so membership
1858+ // stays valid and the delete proceeds (scrubbing only the main ref).
1859+ let mut cfg = empty_config ( ) ;
1860+ cfg. create_map_key ( "channels.discord" , "main" ) . unwrap ( ) ;
1861+ cfg. create_map_key ( "channels.discord" , "backup" ) . unwrap ( ) ;
1862+ cfg. agents . insert (
1863+ "ops" . to_string ( ) ,
1864+ AliasedAgentConfig {
1865+ channels : vec ! [ "discord.main" . into( ) , "discord.backup" . into( ) ] ,
1866+ ..Default :: default ( )
1867+ } ,
1868+ ) ;
1869+ let mut group = PeerGroupConfig {
1870+ channel : "discord" . into ( ) , // bare type
1871+ ..Default :: default ( )
1872+ } ;
1873+ group. agents . push ( AgentAlias :: new ( "ops" ) ) ;
1874+ cfg. peer_groups . insert ( "crew" . to_string ( ) , group) ;
1875+
1876+ let report = delete_with_cascade (
1877+ & mut cfg,
1878+ & channel_kind ( ) ,
1879+ "main" ,
1880+ CascadePolicy :: RefuseOnHard ,
1881+ )
1882+ . expect ( "delete proceeds when a sibling channel keeps membership valid" ) ;
1883+ assert_eq ! (
1884+ report. deleted_entry. as_deref( ) ,
1885+ Some ( "channels.discord.main" )
1886+ ) ;
1887+ assert ! ( !has_channel( & cfg, "main" ) ) ;
1888+ let remaining: Vec < & str > = cfg. agents [ "ops" ]
1889+ . channels
1890+ . iter ( )
1891+ . map ( |c| c. as_str ( ) )
1892+ . collect ( ) ;
1893+ assert_eq ! (
1894+ remaining,
1895+ vec![ "discord.backup" ] ,
1896+ "only the deleted channel is scrubbed; backup survives"
1897+ ) ;
1898+ }
16301899}
0 commit comments