Skip to content

feat(config): delete_with_cascade for channels (#7175) - #7839

Merged
singlerider merged 3 commits into
zeroclaw-labs:masterfrom
NNet-Dev:feat/7175-channel-cascade
Jun 17, 2026
Merged

feat(config): delete_with_cascade for channels (#7175)#7839
singlerider merged 3 commits into
zeroclaw-labs:masterfrom
NNet-Dev:feat/7175-channel-cascade

Conversation

@Nillth

@Nillth Nillth commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

πŸ“‹ Stacked series β€” 5 of 8

Typed delete-with-cascade (#7175) + alias rename (#7468), sliced for review. Opened as draft for CI; merge strictly in order. While the PRs ahead are unmerged this diff is cumulative β€” it includes the un-merged slices below and collapses to its own delta once they land.

Sequence: 1 #7785 βœ… Β· 2 #7830 βœ… Β· 3 #7837 Β· 4 #7838 Β· 5 #7839 ← this PR Β· 6 #7840 Β· 7 #7841 Β· 8 #7842

This slice (own delta): crates/zeroclaw-config/src/alias_refs.rs only β€” +298 / βˆ’29. Cumulative shown until #3–#4 merge: 14 files, +1294 / βˆ’76.

Summary

Adds the channel arm of delete_with_cascade, completing the provider/agent/channel trio. For channels.<type>.<alias> it refuses on HARD refs, otherwise scrubs the SOFT refs, removes the entry via the same generic delete_map_key("channels.<type>", alias) the gateway/CLI use, and re-checks no dangling reference remains. DryRun mutates nothing.

  • SOFT (scrubbed): every agent's channels[] and escalation.alert_channels[] entry naming the target (retain, trimmed to mirror find_all_references + validate()).
  • HARD (refused), two classes β€” both mirror Config::validate():
    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 that member without a required channel.
  • No owned non-config state: channels have no memory/workspace/cron/session rows, so this is the full cascade for the kind β€” no surface-side half.

Review: 1 blocker found + fixed

Bare-type peer-group member orphaning. validate() enforces three channel sites, not two: besides agents.<X>.channels[] (soft) and peer_groups.<g>.channel (hard), every member of a bare-type group must keep some <type>.* channel. The first cut enumerated only the first two β€” so deleting a member's only discord.* channel returned Ok but produced a config that validate() rejects. Fixed: collect_channel_refs now emits a HARD ref at peer_groups.<g>.agents[i] when scrubbing would orphan a bare-group member; the survivor test mirrors validate()'s untrimmed bare-membership comparison exactly. (This guard was subsequently lifted into the #7785 foundation during its review; retained here so the channel arm is self-contained.)

Validation

cargo clippy --workspace --all-targets + cargo fmt --all --check clean at the stack tip. Channel test matrix: scrub-soft+remove Β· refuse-on-peer-group-channel Β· refuse-on-orphaning-bare-member Β· proceed-when-member-keeps-another Β· dry-run Β· not-found. Full CI runs on this draft. Pure library arm (no new surface) β€” exercised through the handle_delete_map_key route /verify'd in slice 4.

Security & Privacy β€” no new fs/network/permissions/secrets; pure in-memory config mutation; synthetic-alias tests.

Compatibility β€” backward compatible; adds a delete arm that previously returned NotImplemented. No surface change.

Rollback (risk: low) β€” git revert <sha>; no migration; no persisted-state effect.

Related #7175.

@singlerider singlerider left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed slice 5 on its own delta (0568269), ignoring the cumulative ancestors. I read the new delete_channel / scrub_channel_refs arm and collect_channel_refs, and cross-checked the HARD detection against Config::validate()'s channel sites in schema.rs. I confirmed the channel suite green locally (cargo test -p zeroclaw-config cascade_channel 6/6, full alias_refs 35/35, clippy clean). This is a clean library arm with the subtle validate-mirroring done correctly. Approving on merits.

This stays draft and merges strictly after slice 4 (#7838), which currently has an outstanding changes-requested from me on the workspace-archive ordering bug. Nothing here depends on that fix, so this approval is independent of it.

🟒 The two HARD channel-ref classes mirror validate() exactly, including the trim asymmetry

The hard part of this slice is the bare-type peer-group orphaning case, and it is right. collect_channel_refs emits a HARD ref at peer_groups.<g>.agents[i] when a bare-type group member's only <type>.* channel is the target. I traced the survivor test against validate()'s bare-membership check (schema.rs ~17682, the None arm):

  • validate's survival test is ch.as_str().starts_with("<type>.") on the untrimmed string.
  • collect's survival test (line ~710) is ch.trim() != target && ch.as_str().starts_with(&type_prefix) - the membership-match clause trims (mirroring what the scrub removes), the survival clause does not trim (mirroring validate).

That asymmetry is deliberate and correct: a whitespace-padded " discord.other" would not count as a surviving channel in validate's eyes, so collect must refuse rather than report a success that yields a config validate rejects. Getting trim-vs-no-trim aligned per-clause is exactly the kind of thing that silently rots; the comment explains why, and the survivor test (cascade_channel_proceeds_when_bare_group_member_keeps_another) plus the orphan test (cascade_channel_refuses_orphaning_bare_group_member) pin both sides.

🟒 The type-level last-alias guard is the right companion to the member-level guard

removes_last_alias (sole key of the type) reports every bare-type group whose channel = "<type>" as HARD, because emptying the [channels.<type>.*] block makes peer_groups.<g>.channel = "<type>" resolve to nothing and validate bails (schema.rs ~17657). This fires at the type level (the block disappears) while the member-level guard fires even when another alias keeps the block present but the member's own only matching channel is the target. Both are needed; neither subsumes the other.

🟒 Scrub mirrors collect, post-condition re-walk, dotted-ref safety

  • scrub_channel_refs drops exactly the two SOFT sites collect marks (agents.<X>.channels[] and escalation.alert_channels[]), trimming to match find_all_references, and never touches peer_groups.<g>.channel (HARD, refused before reaching the scrub).
  • Removal goes through the same generic delete_map_key("channels.<type>", alias) the gateway/CLI use, and the post-mutation find_all_references re-walk converts any future scrub/collect drift into a loud PostCondition error.
  • Correctly distinguishes the dotted target (discord.work) from a bare group channel (discord): the direct peer-group-channel HARD ref only matches the dotted form, and the bare cases are handled by the two type/member guards. DryRun mutates nothing; NotFound short-circuits.

Channels carry no owned non-config state, so unlike the agent arm this is the complete cascade for the kind with no surface-side half. Clean slice.

@singlerider singlerider added this to the v0.8.1 milestone Jun 17, 2026
Nillth added 3 commits June 17, 2026 19:42
Completes agent deletion: the gateway scrubs config references via
delete_with_cascade AND cascades the agent's owned non-config state (memory
rows, workspace dir, cron jobs, ACP sessions, session attribution), refusing
on HARD references.

Surface (zeroclaw-gateway):
- New `agent_owned_state` coordinator: export-then-delete each store into the
  same `agents/_deleted/<alias>-<ts>/` archive (`cascade/*.json` + manifest),
  then remove. Per-store failures are SURFACED in `OwnedStateReport.warnings`
  + manifest + a WARN log β€” never masked as success.
- `handle_delete_map_key` routes `path == "agents"` through `delete_agent_cascade`:
  refuse if any HARD config ref (enabled `heartbeat.agent`) OR live ACP session
  (`killed_at IS NULL`). The live-ACP gate FAILS CLOSED β€” if the ACP store can't
  be read it refuses rather than risk orphaning active sessions. Else
  `delete_with_cascade(Agent)` scrubs config refs + removes the entry (fixing a
  latent gap where the old path left heartbeat/peer-group/delegate refs
  dangling), archive workspace, run the owned-state cascade, persist.

Store methods:
- zeroclaw-infra acp_session_store: `count_live_sessions_by_agent` (HARD signal),
  `list_sessions_by_agent`, `delete_sessions_by_agent` (children cascade via FK).
- zeroclaw-infra `SessionBackend::clear_agent_attribution` (sqlite: agent_alias
  β†’ NULL) β€” keep the possibly channel-shared conversation, drop stale attribution.
- zeroclaw-runtime `cron::{list,remove}_jobs_by_agent` (cron_runs cascade off job_id).
- zeroclaw-api `Memory::{export_agent}` + `purge_agent` now implemented for ALL
  per-agent backends β€” SqliteMemory, LucidMemory (delegates to inner sqlite),
  PostgresMemory (DELETE/SELECT by agent), QdrantMemory (scroll + delete_points)
  β€” so non-sqlite backends no longer silently orphan agent memory.

Tests: memory export_agent (only that agent's rows, no delete); acp live-count
+ delete-by-agent. Full workspace builds; clippy --all-targets (incl. all memory
features) + fmt clean.

Persistence fix: save_dirty writes only marked-dirty paths, and the config
cascade does not mark dirty. delete_agent_cascade previously marked only
agents.<alias>, so a soft-ref scrubbed in ANOTHER entry (another agent's
delegates, a peer group's agents) was correct in memory but left STALE on disk
and reappeared as a dangling reference on the next config reload (which
validate() then rejects). The handler now marks every entry the cascade
touched β€” the removed entry plus each scrubbed referrer's entry β€” via
delete_cascade_dirty_paths/dirty_entry_for (mirrors rename's
RenameReport.dirty_paths). + a dirty_entry_for unit test.
… the entry (zeroclaw-labs#7175)

Review (zeroclaw-labs#7838, singlerider): delete_agent_cascade resolved
working.agent_workspace_dir(alias) AFTER delete_with_cascade removed the agents
entry. agent_workspace_dir only returns an operator-set custom workspace.path
while the entry exists, so for a custom-workspace agent the resolution fell
through to the default install_root/agents/<alias>/workspace path;
workspace.exists() then found nothing and the archive was silently skipped,
leaving the real workspace on disk and defeating export-then-delete
recoverability precisely for the agents most likely to hold operator data.

Resolve the workspace dir before the cascade; archive after. + a regression
test (delete_cascade_resolves_custom_workspace_before_removing_entry) pinning
that agent_workspace_dir yields the custom path while the entry is present and
the default once removed, locking the ordering the handler relies on.
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.
@Nillth
Nillth force-pushed the feat/7175-channel-cascade branch from 0568269 to b3c1191 Compare June 17, 2026 10:00
@Audacity88 Audacity88 added enhancement New feature or request risk: medium labels Jun 17, 2026
@singlerider
singlerider marked this pull request as ready for review June 17, 2026 22:21
@singlerider
singlerider requested a review from Audacity88 as a code owner June 17, 2026 22:21

@singlerider singlerider left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at b3c1191 (my prior APPROVED was at 0568269). The head moved because the stack was rebased, not because the channel arm changed. I diffed alias_refs.rs between the two commits and md5'd the three channel functions: delete_channel, scrub_channel_refs, and collect_channel_refs are byte-identical to what I approved. The only file deltas are already-merged-and-approved slice-3 material now appearing in the rebased base (the RefStrength::Hard doc-comment correction and the cascade_agent_refuses_when_solely_owned_channel test from c02c8aa). I re-ran the channel suite green on the new head (cargo test -p zeroclaw-config cascade_channel 6/6). Re-approving.

The channel cascade review stands unchanged: both HARD channel-ref classes mirror Config::validate() exactly including the deliberate trim-vs-no-trim asymmetry, the type-level and member-level orphan guards are correct companions, scrub mirrors collect, and the post-condition re-walk is fail-closed. RESOLVED βœ… status for everything in the prior pass.

One note for the merge mechanics, not a review finding: the rebased head now carries the slice-4 fix 927921e (resolve the workspace dir before the cascade removes the entry) in its history, which correctly fixes the πŸ”΄ I raised on #7838 - I confirmed the fix and its regression test (delete_cascade_resolves_custom_workspace_before_removing_entry) green. That is the right fix. But #7838 itself is still open with my changes-requested on record. Merging this slice does not substitute for landing slice 4 in order through its own PR; sequence the merges so slice 4 lands first and shows Merged rather than being swept in here.

@singlerider
singlerider merged commit 711de3e into zeroclaw-labs:master Jun 17, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

config Auto scope: src/config/** changed. cron Auto scope: src/cron/** changed. enhancement New feature or request gateway Auto scope: src/gateway/** changed. memory Auto scope: src/memory/** changed. runtime Auto scope: src/runtime/** changed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants