Skip to content

cli: unsubscribe orphaned users before rotating or deleting a feed - #4119

Merged
nikw9944 merged 6 commits into
mainfrom
nikw9944/infra-2113
Jul 30, 2026
Merged

cli: unsubscribe orphaned users before rotating or deleting a feed#4119
nikw9944 merged 6 commits into
mainfrom
nikw9944/infra-2113

Conversation

@nikw9944

@nikw9944 nikw9944 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary of Changes

  • doublezero feed update (when --group drops a group) and doublezero feed delete now unsubscribe the EdgeSeat users the change would orphan — or fail closed listing them, unless --force-unsubscribe is passed.
  • 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/DeleteFeed can break it and the program cannot notice — there is no reverse index from a Feed to its subscribers — and the resulting state lets a later unsubscribe release a seat the user still needs.
  • Unsubscribe-then-rotate is the ordering that only ever passes through legal states: a user holding fewer groups than their feeds offer is fine. Rotate-then-unsubscribe would publish the illegal state for as long as the cleanup takes.
  • A rename and an update without --group are unchanged and scan nothing. Any --group set 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.
  • Coverage is per role and mirrors seat release: a feed covers a group only when it is seated on an accepted pass and among the seats the user consumed (user.feed_pks), and a role the pass's own mgroup_pub_allowlist/mgroup_sub_allowlist still 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 through UpdateMulticastGroupRoles would be a grant with different authorization; the automated path stays removal-only.

The removals go through UpdateMulticastGroupRoles, which needs USER_ADMIN (or foundation membership) on the payer in addition to the FEED_AUTHORITY these commands already require. The first removal failing leaves the feed untouched, so that surfaces before anything is submitted.

Two notes for reviewers

  • UpdateFeedSubscription follow-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: false drops the groups and releases the seat, and its GroupNotInFeed validation passes because removals run pre-rotation. That switch also moves the required permission from USER_ADMIN to ACCESS_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.
  • Deleted-feed seat residue is deliberately out of scope. The guard covers subscriptions only. FeedSeat entries 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

  • New tests
  • Fixed three user::create_subscribe tests that were latently order-dependent: they passed a Pubkey::new_unique() string as --subscriber and relied on its base58 length landing outside parse_pubkey's 43–44 char window, which varies with the process-global counter. Pinned to a fixed short pubkey.

nikw9944 added 2 commits July 30, 2026 15:51
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
@nikw9944 nikw9944 self-assigned this Jul 30, 2026
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
@nikw9944
nikw9944 marked this pull request as ready for review July 30, 2026 17:07

Copilot AI 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.

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 --group is provided) and always into feed delete, plus add CLI-level tests for both verbs and their retry behavior.
  • Stabilize user create-subscribe tests 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.

Comment thread CHANGELOG.md Outdated
Comment thread smartcontract/cli/src/feed/guard.rs
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
@nikw9944

Copy link
Copy Markdown
Contributor Author

Addressed all review feedback.

Allowlist coverage (guard.rs) — fixed in 3491967. Coverage is now per role: a role the pass's own mgroup_pub_allowlist/mgroup_sub_allowlist still authorizes (on any accepted pass, either PDA) is not orphaned, and a forced removal re-asserts the covered role so only the uncovered one is removed. The re-assertion is safe onchain: a kept role is only ever allowlist-covered — feed coverage is role-agnostic, so it can never produce a mixed case — and check_mgroup_allowlists passes exactly for allowlist-covered roles. Orphan now carries explicit remove_*/keep_* fields and the printed plan shows both. New planner tests cover sub-allowlist coverage, the pub/sub role mismatch, the mixed removal that keeps the covered role, and an allowlist on the other PDA.

Stale drop-vs-additive decision (update.rs/delete.rs) — fixed in 3491967. The guard now runs whenever --group is present and on every delete; its per-round scan re-derives the dropped set, so a group added between the initial get_feed read and the submit is caught. An additive update costs one scan and finds nothing to do.

Changelog PR reference and pass indexing in plan() — fixed in 8112c22, replied in-thread.

cargo test -p doublezero-serviceability-cli (394 passing), make rust-fmt, and clippy -Dwarnings are clean. The runbook in malbeclabs/infra#2129 was updated to match the per-role coverage and always-scan behavior.

@martinsander00 martinsander00 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.

read through the guard and the ix 116 side. two things about the keep_* path and the coverage set, rest is minor.

Comment thread smartcontract/cli/src/feed/guard.rs Outdated
Comment thread smartcontract/cli/src/feed/guard.rs
Comment thread smartcontract/cli/src/feed/guard.rs Outdated
Comment thread smartcontract/cli/src/feed/update.rs
Comment thread smartcontract/cli/src/feed/update.rs Outdated
…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
@nikw9944

Copy link
Copy Markdown
Contributor Author

Addressed the full review in 4876bec (replies in each thread):

  • Mixed-role orphans fail closed. The keep_* re-assertion was broken exactly as described — a true is a grant (removal_only flips to ACCESS_PASS_ADMIN, the user must be Activated, and the allowlist is re-checked against the single SDK-resolved pass). The automated path is now strictly removal-only; an orphan whose other role is still allowlisted fails the run closed with the user flagged for manual cleanup, so the USER_ADMIN hint stays accurate.
  • Feed coverage requires the consumed seat. Coverage now intersects with user.feed_pks, mirroring UnsubscribeFeed's retained-feeds check — a seated feed the user never consumed no longer keeps a group alive with no seat accounting for it.
  • Disappeared-feed message no longer claims "nothing was changed"; it says the feed change was not submitted.
  • Tests: new mixed-role force test proves nothing reaches the SDK on that path; the round-limit abort pins its error message and the misleading comment is reworded; planner tests cover consumed vs. unconsumed seats (including the rotated feed's own consumed seat still not counting).

396 tests passing, fmt/clippy clean. The runbook in malbeclabs/infra#2129 (b7c1686) and the changelog entry were updated to match.

Comment thread smartcontract/cli/src/feed/guard.rs Outdated
Comment thread smartcontract/cli/src/user/create_subscribe.rs Outdated
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
@nikw9944

Copy link
Copy Markdown
Contributor Author

Both follow-up nits addressed in a56974d: plan() is now find_orphans() with its doc comment cut to the coverage rule, and the repeated pinned-pubkey comment in the create_subscribe tests is hoisted into a single short_mgroup_pubkey() helper. 396 tests passing, fmt/clippy clean.

@nikw9944
nikw9944 merged commit cfd080a into main Jul 30, 2026
37 checks passed
@nikw9944
nikw9944 deleted the nikw9944/infra-2113 branch July 30, 2026 19:39
nikw9944 added a commit that referenced this pull request Jul 31, 2026
…#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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants