Skip to content

Commit 0568269

Browse files
committed
feat(config): delete_with_cascade for channels (#7175)
Adds the channel arm of delete_with_cascade, completing the provider/agent/channel trio. On top of the agent cascade. - delete_with_cascade(.., AliasKind::Channel { channel_type }, ..) for channels.<type>.<alias>: refuses on HARD refs, otherwise scrubs the SOFT references — drops the alias from every agent's channels[] and from escalation.alert_channels[] (retain, trimmed to mirror find/validate) — removes the entry via the generic delete_map_key("channels.<type>", alias) the gateway/CLI already use, and verifies no dangling reference remains. DryRun mutates nothing. - Two HARD channel-ref classes, both mirroring Config::validate() (schema.rs:17408-17479): 1. a mandatory dotted `peer_groups.<g>.channel` naming the target; 2. a member of a BARE-type group (`channel = "discord"`) whose only `<type>.*` channel is the target — scrubbing it would leave the member without a required channel, yielding a config validate() rejects. Detected and refused (fail-closed) rather than reported as a success. The survivor test mirrors validate()'s untrimmed bare membership check exactly. Channels carry no owned non-config state (unlike agents), so this is the full cascade for the kind. TTS/transcription providers remain NotImplemented. 6 channel cascade tests (scrub+remove, refuse-on-hard-peer-group, refuse-on-orphaning-bare-group-member, proceed-when-member-keeps-another, dry-run, not-found). zeroclaw-config suite green; clippy --all-targets + fmt clean.
1 parent ad82a74 commit 0568269

1 file changed

Lines changed: 298 additions & 29 deletions

File tree

crates/zeroclaw-config/src/alias_refs.rs

Lines changed: 298 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -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).
246246
pub 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

Comments
 (0)