cli: unsubscribe orphaned users before rotating or deleting a feed - #4119
Conversation
An EdgeSeat user's multicast groups must stay a subset of the groups carried by the feeds on their access pass — serviceability relies on that when deciding whether an unsubscribe releases a feed's seat. `UpdateFeed` and `DeleteFeed` can break it and the program cannot notice: there is no reverse index from a Feed to its subscribers. The resulting illegal state lets a later unsubscribe release a seat the user still needs. `doublezero feed update` and `feed delete` now scan for the users a group drop would orphan and fail closed, listing them, unless `--force-unsubscribe` is passed — in which case the memberships are removed first, re-verified against a fresh scan, and only then is the feed change submitted. Unsubscribe-then-rotate only ever passes through legal states. Ref malbeclabs/infra#2113
Review follow-ups on the feed guard: - The program accepts an access pass at either the exact-IP PDA or the shared UNSPECIFIED one, so mirroring the SDK read path's preference for the dynamic pass let a Prepaid pass hide a live EdgeSeat pass and skip a seated user as "not feed-gated". Coverage is now the union of seats over every accepted EdgeSeat pass, and a consumed seat (`user.feed_pks`) keeps a user in scope on its own. - Re-derive the dropped group set from each round's fresh scan instead of pinning the set read before the loop, so a group added to the feed mid-run is caught too. - Test membership before looking up the device, so an unrelated user with a dangling `device_pk` no longer blocks every rotation. - Stop asserting USER_ADMIN as the cause of every removal failure. Ref malbeclabs/infra#2113
Review follow-ups on the feed guard: - The program authorizes multicast roles through the pass's own mgroup_pub_allowlist / mgroup_sub_allowlist for every pass type, so an EdgeSeat user can legitimately hold a role with no feed involved. The planner now treats a per-role allowlist entry on any accepted pass as coverage, and a forced removal re-asserts the covered role instead of clearing both — only the orphaned role goes away. Leaving the covered role is safe for the invariant: seat release never keys on a group the feeds no longer carry. - Run the guard whenever `--group` is present (and on every delete) instead of deciding drop-vs-additive from the pre-loop feed read, so a group added between that read and the submit is still caught by the guard's fresh per-round scan. An additive update now costs one scan and finds nothing to do. Ref malbeclabs/infra#2113
There was a problem hiding this comment.
Pull request overview
This PR updates the serviceability CLI feed administration flow to prevent doublezero feed update --group … and doublezero feed delete from leaving EdgeSeat users subscribed to multicast groups that are no longer covered by any feed seated on their access pass (unless explicitly forced to unsubscribe first).
Changes:
- Add a new feed “orphan guard” planner/executor that scans current onchain state, identifies subscriptions that would become invalid after a feed update/delete, and either fails closed (default) or unsubscribes them first (
--force-unsubscribe). - Wire the guard into
feed update(only when--groupis provided) and always intofeed delete, plus add CLI-level tests for both verbs and their retry behavior. - Stabilize
user create-subscribetests by using a deterministic short base58 pubkey string that reliably exercises the code-resolution path.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| smartcontract/cli/src/feed/guard.rs | New guard: scans users/passes/devices/feeds, plans orphaned roles, and optionally unsubscribes before allowing feed mutation. |
| smartcontract/cli/src/feed/update.rs | Adds --force-unsubscribe, runs the guard on group replacement, and adds mock-client CLI tests for fail-closed / forced unsubscribe / retry loop. |
| smartcontract/cli/src/feed/delete.rs | Adds --force-unsubscribe, runs the guard before delete, and adds mock-client CLI tests for fail-closed / forced unsubscribe. |
| smartcontract/cli/src/feed/mod.rs | Exposes the new guard module. |
| smartcontract/cli/src/user/create_subscribe.rs | Makes several tests deterministic by replacing Pubkey::new_unique() with a fixed short base58 pubkey. |
| CHANGELOG.md | Documents the CLI breaking behavior change and the new --force-unsubscribe flag. |
Review follow-ups: pre-index access passes by user_payer once per plan() call instead of rescanning every pass per affected user, and add this PR's reference to the changelog entry per the file's convention. Ref malbeclabs/infra#2113
|
Addressed all review feedback. Allowlist coverage (guard.rs) — fixed in 3491967. Coverage is now per role: a role the pass's own Stale drop-vs-additive decision (update.rs/delete.rs) — fixed in 3491967. The guard now runs whenever Changelog PR reference and pass indexing in
|
…coverage Review follow-ups on the feed guard: - Re-asserting an allowlist-covered role as `true` is a grant, not "leave it alone": it flips the required permission to ACCESS_PASS_ADMIN, requires the user be Activated, and re-checks the allowlist against the single pass the SDK resolves. So a mixed-role orphan (one role removable, the other still allowlisted) now fails the run closed for manual handling, and the automated path stays removal-only — keeping the USER_ADMIN hint accurate. - Feed coverage now requires the covering feed to be among the seats the user actually consumed (user.feed_pks), mirroring UnsubscribeFeed's retained-feeds check. A seated feed the user never consumed would keep the group alive with no seat accounting for it. - The disappeared-feed error no longer claims "nothing was changed" (from round 2 onward removals have landed); it says the feed change was not submitted. - Tests: mixed-role force run bails before submitting; the round-limit abort pins its error message; consumed-vs-unconsumed seat coverage. Ref malbeclabs/infra#2113
|
Addressed the full review in 4876bec (replies in each thread):
396 tests passing, fmt/clippy clean. The runbook in malbeclabs/infra#2129 (b7c1686) and the changelog entry were updated to match. |
Review nits: plan() becomes find_orphans() with its doc comment trimmed to the coverage rule, and the thrice-repeated pinned-pubkey explanation in the create_subscribe tests moves into one short_mgroup_pubkey() helper. Ref malbeclabs/infra#2113
|
Both follow-up nits addressed in a56974d: |
…#2114) The #4119 feed update/delete orphan cleanup landed using the old group_pk field; its removal calls now pass one-element group_pks vecs (per-orphan reporting unchanged).
Summary of Changes
doublezero feed update(when--groupdrops a group) anddoublezero feed deletenow unsubscribe the EdgeSeat users the change would orphan — or fail closed listing them, unless--force-unsubscribeis passed.UpdateFeed/DeleteFeedcan break it and the program cannot notice — there is no reverse index from aFeedto its subscribers — and the resulting state lets a later unsubscribe release a seat the user still needs.--groupare unchanged and scan nothing. Any--groupset runs the guard — it re-derives the dropped set from its own fresh scan, so an additive change costs one scan and finds nothing to do.user.feed_pks), and a role the pass's ownmgroup_pub_allowlist/mgroup_sub_allowliststill authorizes is not orphaned (the program runs that check for every pass type). A mixed case — one role removable while the allowlist still covers the other — fails closed even with the flag, since preserving a role throughUpdateMulticastGroupRoleswould be a grant with different authorization; the automated path stays removal-only.The removals go through
UpdateMulticastGroupRoles, which needsUSER_ADMIN(or foundation membership) on the payer in addition to theFEED_AUTHORITYthese commands already require. The first removal failing leaves the feed untouched, so that surfaces before anything is submitted.Two notes for reviewers
UpdateFeedSubscriptionfollow-up. Once the serviceability: feed-scoped multicast subscription for EdgeSeat access passes #4108 series lands a CLI/SDK surface for instruction 117, the unsubscribe leg should switch to it:subscriber: falsedrops the groups and releases the seat, and itsGroupNotInFeedvalidation passes because removals run pre-rotation. That switch also moves the required permission fromUSER_ADMINtoACCESS_PASS_ADMIN. Nothing here conflicts with serviceability: add SubscribeFeed and UnsubscribeFeed for EdgeSeat feeds #4113 — it rewrites the add path and the internal authorization plumbing; the guard only issues removals through the public SDK command.FeedSeatentries naming a deleted feed stay on existing access passes; that is a known bug with a fix in progress elsewhere, and this PR leaves it there so the two fixes do not collide.Testing Verification
user::create_subscribetests that were latently order-dependent: they passed aPubkey::new_unique()string as--subscriberand relied on its base58 length landing outsideparse_pubkey's 43–44 char window, which varies with the process-global counter. Pinned to a fixed short pubkey.