refactor(device-set): group RPCs + remove generic collection membership (3/4)#466
Conversation
🔐 Codex Security Review
Review SummaryOverall Risk: MEDIUM Findings[MEDIUM] RPC Rename And Removal Break Existing Clients
[MEDIUM] Rack Assignment Audit Can Report Unrelated Site Changes
NotesNo changed shell-out, Nmap/mDNS, plugin, Docker, Rust, frontend XSS, or pool/wallet rewrite logic stood out in the reviewed diff. I did not run tests; this was a read-only diff review. Generated by Codex Security Review | |
ac47c99 to
c73d160
Compare
7acd632 to
9266fb5
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9266fb5d45
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
c73d160 to
3931213
Compare
9266fb5 to
fe970da
Compare
3931213 to
44a1f0c
Compare
fe970da to
17fa0d1
Compare
44a1f0c to
184614e
Compare
17fa0d1 to
d75d80e
Compare
184614e to
b53a01f
Compare
354b458 to
24720f1
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 24720f12db
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Adds AddDevicesToGroup / RemoveDevicesFromGroup as type-specific group membership procedures, swaps AssignDevicesToRack.device_identifiers to common.v1.DeviceSelector (device_list-only; all_devices rejected with InvalidArgument), and removes the legacy generic procedures: - device_set.v1: AddDevicesToDeviceSet, RemoveDevicesFromDeviceSet - collection.v1: AddDevicesToCollection, RemoveDevicesFromCollection Server changes: - Group RPCs reject rack targets with InvalidArgument - Domain Service methods replaced; store-level collection helpers retained as shared infrastructure for the new RPCs - fleetimport migrates to AddDevicesToGroup + AssignDevicesToRack (the atomic prior-rack-clear is a no-op for fresh imports) - DeviceSetService added to gRPC reflection so tooling can discover the new RPCs via grpcurl -list - Handler permission map updated (collection legacy entries dropped; device_set entries swapped) Client changes: - RackOverviewPage drag flow uses assignDevicesToRack (atomic single miner move clears any prior rack membership, the desired behavior) - MinerActionModalStack group operations use addDevicesToGroup - useDeviceSets exposes addDevicesToGroup / removeDevicesFromGroup with signal support; legacy addDevicesToDeviceSet / removeDevicesFromDeviceSet hooks removed - assignDevicesToRack hook constructs DeviceSelector(device_list) internally so callers still pass deviceIdentifiers: string[] Tests: - Service: AddDevicesToGroup / RemoveDevicesFromGroup happy path + rack-target rejection; existing add-to-collection cascade tests removed (rack adds flow through AssignDevicesToRack) - Handler: AddDevicesToGroup / RemoveDevicesFromGroup happy path, rack-target rejection, permission gate; AssignDevicesToRack all_devices selector rejection; existing assign tests updated to the new selector shape Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
24720f1 to
9fdfa4a
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9fdfa4af5c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
common.v1.DeviceSelector.device_list replaced the deprecated
`repeated string device_identifiers` field but did not carry over its
buf.validate constraints (min_items: 1, max_items: 10000,
items.string.{min_len: 1, max_len: 256}). Empty strings + oversized
lists flowed through to the store layer where they either silently
matched no rows (while AssignedCount still reported based on requested
count) or amplified ANY()-expansion load.
Enforce the same caps centrally in deviceresolver.ValidateDeviceIdentifiers
and call it from both entry points that unwrap the device_list variant:
deviceresolver.resolveExplicitDevices (used by the group RPCs) and
deviceset.identifiersFromAssignSelector (AssignDevicesToRack's direct
unwrap). Handler tests pin the InvalidArgument code for empty,
over-length, and oversized cases.
AssignDevicesToRackResult.AssignedCount returns final-membership count (devices whose membership now points at the target rack, including devices that were already there before the call). The bulk importer previously used it to accumulate devices_assigned, which overstated the count on re-imports that re-targeted devices already in the rack — the old AddDevicesToCollection.AddedCount returned only newly-inserted rows. Expose AddDevicesToCollection's row count separately as NewlyAssignedCount on the service result (no proto change — internal Go-only) and switch the importer's devices_assigned accumulator to use it. The activity log + handler response continue to use the final-membership semantics on AssignedCount. Adds importer_rack_assign_test.go covering the re-import path with a fake CollectionManager that returns AssignedCount=3 + NewlyAssignedCount=1 to confirm only the newly-inserted count flows into the result.
Adds TestAddDevicesToGroup_RejectsCrossOrgTarget to pin the wire-visible NotFound code when target_group_id belongs to a different org. The service layer's GetCollection(orgID, id) already enforces the org-scoped predicate, but this test exercises the end-to-end path so a future refactor cannot accidentally leak the existence of cross-org collections. The all_devices rejection (T1) and rack-target rejection on group RPCs (T2) are already covered by the existing TestAssignDevicesToRack_RejectsAllDevicesSelector, TestAddDevicesToGroup_RejectsRackTarget, and TestRemoveDevicesFromGroup_RejectsRackTarget tests; no new coverage needed there.
gocritic unlambda: replace `func(s string) string { return networking.NormalizeMAC(s) }`
with the direct function reference.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR (3/4) continues the device-set refactor by replacing the legacy “generic collection membership” RPC surface with type-specific DeviceSetService RPCs, migrating remaining server and client call sites, and tightening rack assignment to use common.v1.DeviceSelector (explicit list only).
Changes:
- Introduces type-specific group membership RPCs (
AddDevicesToGroup,RemoveDevicesFromGroup) and removes legacy add/remove RPCs fromcollection.v1anddevice_set.v1. - Refactors rack assignment (
AssignDevicesToRack) to acceptcommon.v1.DeviceSelectorand rejectsall_devicesat the handler boundary; adds server-side identifier list validation. - Migrates client flows (rack drag/slot flow + miner group actions) and importer paths to the new RPCs, plus updates reflection and permissions wiring.
Reviewed changes
Copilot reviewed 18 out of 24 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| server/internal/handlers/middleware/rpc_permissions.go | Removes legacy RPC permission entries; adds permissions for new group RPC procedures. |
| server/internal/handlers/deviceset/translate.go | Translates DeviceSelector → identifier list for rack assignment; rejects unsupported selector variants. |
| server/internal/handlers/deviceset/handler.go | Switches handler endpoints to AddDevicesToGroup/RemoveDevicesFromGroup; threads selector-based rack assignment translation errors. |
| server/internal/handlers/deviceset/handler_test.go | Adds handler tests for selector rejection/validation and new group RPC behavior + permission checks. |
| server/internal/handlers/collection/handler.go | Deletes legacy collection membership RPC handlers. |
| server/internal/domain/foremanimport/service_test.go | Updates fakes to match new type-specific collection manager interface. |
| server/internal/domain/fleetimport/importer.go | Migrates importer group adds and rack assignment to new type-specific methods; uses NewlyAssignedCount. |
| server/internal/domain/fleetimport/importer_rack_assign_test.go | Adds coverage ensuring rack re-import uses NewlyAssignedCount semantics. |
| server/internal/domain/deviceresolver/resolver.go | Centralizes identifier-list validation (count + per-item bounds) for explicit selectors. |
| server/internal/domain/collection/service.go | Adds group-specific add/remove APIs; extends rack assignment result with NewlyAssignedCount. |
| server/internal/domain/collection/service_test.go | Updates tests to cover group add/remove flows and rack-target rejection. |
| server/generated/grpc/device_set/v1/device_setv1connect/device_set.connect.go | Generated — updates connect stubs/procedure constants for new RPCs. |
| server/generated/grpc/collection/v1/collectionv1connect/collection.connect.go | Generated — removes connect stubs/procedure constants for legacy RPCs. |
| server/cmd/fleetd/main.go | Adds DeviceSetService to reflection-enabled services list. |
| proto/device_set/v1/device_set.proto | Defines new group RPCs; updates rack assignment request to DeviceSelector (explicit list only). |
| proto/collection/v1/collection.proto | Removes legacy generic add/remove membership RPCs/messages. |
| client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.tsx | Migrates rack drag/slot flow to assignDevicesToRack. |
| client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.test.tsx | Updates mocks for the new hook surface. |
| client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionModalStack.tsx | Migrates group operation flow to addDevicesToGroup. |
| client/src/protoFleet/api/useDeviceSets.ts | Adds new group hooks + selector-based rack assignment; integrates AbortSignal handling. |
| client/src/protoFleet/api/generated/device_set/v1/device_set_pb.ts | Generated — reflects proto changes for new RPCs and selector field changes. |
| client/src/protoFleet/api/generated/collection/v1/collection_pb.ts | Generated — reflects removal of legacy membership RPCs. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5eac3bf611
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
… rack selector comment addDevicesToGroup and removeDevicesFromGroup now short-circuit with an "No devices selected." error when no allDevices flag is set and the identifier list is empty, instead of issuing an RPC that the server rejects with InvalidArgument. Also rewords the assignDevicesToRack selector comment to accurately describe why we always emit device_list (the server rejects all_devices for that RPC) rather than implying the construction avoids InvalidArgument on empty input. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ToRack The atomic rack-reassign path lost the cross-site cascade audit trail that the old AddDevicesToCollection flow emitted: site_id on the activity event, plus Metadata keys (site_cascade, final_site_id, site_reassigned_count, device_site_changes, total_affected, truncated). Slot-search rack moves and any reparent that crosses sites silently dropped this audit data. Capture per-device priors via GetDeviceSiteIDsByMembership before CascadeAddedDeviceSites runs (same ordering as the CreateCollection cascade path), feed buildDeviceSiteChanges to produce the metadata shape audit consumers already understand, and emit them on the assign_devices_to_rack activity event. The no-op same-site case keeps the event Metadata-free so consumers can distinguish "implicit move" from "membership-only change". Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Part 3 of 4 — Group RPCs + Generic Collection-Membership Removal
1. Summary
Replaces the legacy generic device-membership surface (
AddDevicesToDeviceSet/RemoveDevicesFromDeviceSetindevice_set/v1, plusAddDevicesToCollection/RemoveDevicesFromCollectionincollection/v1) with type-specific RPCs:AddDevicesToGroup/RemoveDevicesFromGroupfor many-to-many group membership, and the existing atomicAssignDevicesToRackfor racks. After this PR, every device-membership operation in the fleet hierarchy is type-checked at the proto layer, atomic where atomicity matters (racks), and additive where the data model allows it (groups). No generic backdoor that bypasses the orphan-window guarantees the earlier PRs in this series introduced.Stack
Diff is relative to
main(no longer stacked on #464 after #464 merged).Required context from upstream:
AssignDevicesToRack(introduced in refactor(reparent): atomic reparent foundation (1/4) #463) — atomic single-RPC rack reparent. This PR migratesRackOverviewPagedrag flow to it and removes the last legacy callers inMinerReparentPickerindirectly (after feat(sites): atomic cross-site reparent (2/4) #464 collapsed the cross-site path).DeviceSelectorshape (common.v1) — already exists. This PR adopts it onAssignDevicesToRackand the new group RPCs.Out of scope, lands elsewhere:
AssignDevicesToSiteforce-clear andAssignDevicesToRack(resolves the40P01deadlock window into a true lock-order serialization) — filed as a follow-up, mentioned in feat(sites): atomic cross-site reparent (2/4) #464.2. How it works
Pre-PR device-membership shape (generic, runtime-checked):
AddDevicesToDeviceSet(device_set_id, device_selector)andRemoveDevicesFromDeviceSet(device_set_id, device_selector)accept either a rack OR a group. The service inspects the collection type at runtime and branches.collection/v1(AddDevicesToCollection/RemoveDevicesFromCollection) for legacy reasons — the wire surface predates the device_set renaming.AssignDevicesToRack(target_rack_id, repeated string device_identifiers)— added in refactor(reparent): atomic reparent foundation (1/4) #463 — handles rack membership atomically but the legacy generic surface still allows a caller to bypass it.Post-PR device-membership shape (type-specific):
AddDevicesToGroup(target_group_id, DeviceSelector)— additive (many-to-many between miner and group). Service does an org-scopedGetCollectioninside the tx, rejects non-COLLECTION_TYPE_GROUPtargets withInvalidArgument, then calls the existing store-levelAddDevicesToCollection(kept as shared infrastructure).RemoveDevicesFromGroup(target_group_id, DeviceSelector)— same type guard, calls store-levelRemoveDevicesFromCollection.AssignDevicesToRack— request shape nowDeviceSelector device_selectorinstead ofrepeated string device_identifiers. Handler accepts only thedevice_listvariant;all_devicesis rejected at the translate layer withInvalidArgument. Identifier validation is enforced by a sharedValidateDeviceIdentifiershelper (caps: 10k items, 256 chars per identifier, no empty strings).AddDevicesToDeviceSet/RemoveDevicesFromDeviceSet(device_set/v1) — removed from the proto, removed from the wire-permissions map, removed from the handler.AddDevicesToCollection/RemoveDevicesFromCollection(collection/v1) — removed from the proto, removed from the wire-permissions map, removed from the handler. Store-level methods stay as shared infrastructure used by the new group RPCs and byAssignDevicesToRackinternally.Service.AddDevicesToCollection/Service.RemoveDevicesFromCollection(the in-process Go-facing methods that the gRPC handlers used to call, and thatfleetimportcalled directly) — also removed.fleetimportmigrates to the type-specific Service methods.fleetimportmigration:Service.AddDevicesToGroup.Service.AssignDevicesToRack. Importer accumulatesdevicesAssignedfromNewlyAssignedCount(Go-only field added toAssignDevicesToRackResult) rather thanAssignedCount, so idempotent re-imports report only newly-added rows — matches the oldAddDevicesToCollection.AddedCountsemantics.Client UI migration:
RackOverviewPagedrag flow →assignDevicesToRack(wasaddDevicesToDeviceSet). Atomic-replace semantics correct: a single-miner drag from one rack to another now atomically clears the prior rack row + adds the new one.MinerActionModalStackgroup-add flow →addDevicesToGroup(wasaddDevicesToDeviceSet).useDeviceSetshook dropsaddDevicesToDeviceSet/removeDevicesFromDeviceSet, addsaddDevicesToGroup/removeDevicesFromGroup.gRPC reflection:
DeviceSetServiceis now inreflectEnabledServices(server/cmd/fleetd/main.go). Agents usinggrpcurl -listcan discoverAssignDevicesToRack,AddDevicesToGroup,RemoveDevicesFromGroup. The auth interceptor chain remains the security boundary; reflection only exposes service shape.Auth posture: unchanged from pre-PR. The new group RPCs and
AssignDevicesToRackare all gated onPermRackManage(no separatePermGroupManageexists in the catalog; the legacy pair was on the same permission). Catalog split for groups is filed as a future-work follow-up.3. Diagrams
Device-membership surface — before and after
flowchart TB subgraph Pre["Pre-PR: generic + runtime type-check"] A1["AssignDevicesToRack<br/>(rack-specific, atomic)<br/>device_set/v1"] -.->|"can also be used<br/>but caller may bypass"| A4 A2["AddDevicesToDeviceSet<br/>generic; runtime branches on type<br/>device_set/v1"] --> A4["store.AddDevicesToCollection"] A3["AddDevicesToCollection<br/>duplicate generic surface<br/>collection/v1"] --> A4 A5["RemoveDevicesFromDeviceSet<br/>device_set/v1"] --> A6["store.RemoveDevicesFromCollection"] A7["RemoveDevicesFromCollection<br/>collection/v1"] --> A6 end subgraph Post["Post-PR: type-specific at the proto layer"] B1["AssignDevicesToRack<br/>rack-only; DeviceSelector<br/>device_set/v1"] --> B4["store.AddDevicesToCollection<br/>+ store.RemoveDevicesFromAnyRack<br/>(shared infrastructure)"] B2["AddDevicesToGroup<br/>group-only; type-checked<br/>device_set/v1"] --> B4 B3["RemoveDevicesFromGroup<br/>group-only; type-checked<br/>device_set/v1"] --> B5["store.RemoveDevicesFromCollection"] endAddDevicesToGroup request flow
sequenceDiagram participant Client participant Handler as deviceset.Handler participant Auth as middleware.RequirePermission participant Svc as collection.Service participant Resolver as deviceresolver participant Store as collectionStore participant DB Client->>Handler: AddDevicesToGroup target_group_id, DeviceSelector Handler->>Auth: PermRackManage Auth-->>Handler: ok Handler->>Resolver: resolveExplicitDevices selector Resolver->>Resolver: ValidateDeviceIdentifiers caps + non-empty Resolver-->>Handler: identifiers Handler->>Svc: AddDevicesToGroup orgID, target_group_id, identifiers Svc->>DB: BEGIN Svc->>Store: GetCollection orgID, target_group_id alt collection is not GROUP Svc-->>Handler: InvalidArgument Svc->>DB: ROLLBACK else collection is GROUP Svc->>Store: AddDevicesToCollection Store->>DB: INSERT ... ON CONFLICT DO NOTHING Svc->>DB: COMMIT Svc-->>Handler: added_count end Handler-->>Client: responseRemoved wire surface
flowchart LR subgraph Removed["Removed wire RPCs"] D1["AddDevicesToDeviceSet"] D2["RemoveDevicesFromDeviceSet"] D3["AddDevicesToCollection"] D4["RemoveDevicesFromCollection"] end subgraph Replacements["Replacement RPCs"] R1["AddDevicesToGroup"] R2["RemoveDevicesFromGroup"] R3["AssignDevicesToRack"] R4["AssignDevicesToRack<br/>target_rack_id unset"] end D1 -->|group target| R1 D1 -->|rack target| R3 D2 -->|group target| R2 D2 -->|rack clear| R4 D3 -->|group target| R1 D3 -->|rack target| R3 D4 -->|group target| R2 D4 -->|rack clear| R44. Areas of the code involved
proto/device_set/v1/device_set.proto(modified)AddDevicesToDeviceSet/RemoveDevicesFromDeviceSetRPCs + messages. AddedAddDevicesToGroup/RemoveDevicesFromGroupRPCs + messages. SwappedAssignDevicesToRack.device_identifiers(repeated string, tag 2) →device_selector(common.v1.DeviceSelector, tag 2).proto/collection/v1/collection.proto(modified)AddDevicesToCollection/RemoveDevicesFromCollectionRPCs + messages.collection/v1keeps Create/Get/Update/Delete/List/Stats/Slot/Zone/Type/SaveRack — only the membership-mutation pair is gone.server/generated/grpc/**(modified)client/src/protoFleet/api/generated/**(modified)server/cmd/fleetd/main.go(modified)device_setv1connect.DeviceSetServiceNametoreflectEnabledServices.AssignDevicesToRack,AddDevicesToGroup,RemoveDevicesFromGroup. Auth boundary unchanged.server/internal/handlers/middleware/rpc_permissions.go(modified)AddDevicesToCollection,RemoveDevicesFromCollection,AddDevicesToDeviceSet,RemoveDevicesFromDeviceSet). Added entries forAddDevicesToGroup,RemoveDevicesFromGroup, both onPermRackManage.server/internal/handlers/deviceset/handler.go(modified)AddDevicesToGroup/RemoveDevicesFromGrouphandlers (auth-gated, then service call). UpdatedAssignDevicesToRackhandler to plumb the selector through and surface the translate-layer error for non-device_listvariants.server/internal/handlers/deviceset/translate.go(modified)toAssignDevicesToRackParamsnow extracts identifiers fromDeviceSelector.device_list, rejectsall_devicesand unset variants, and callsValidateDeviceIdentifiers. AddedtoAddDevicesToGroupParams/toRemoveDevicesFromGroupParams.server/internal/handlers/deviceset/handler_test.go(modified)AssignDevicesToRackselector-variant rejection,ValidateDeviceIdentifiersboundary cases (empty string, over-length, over-cap list), cross-org isolation forAddDevicesToGroup.server/internal/handlers/collection/handler.go(modified)AddDevicesToCollection/RemoveDevicesFromCollectionhandler funcs. Handler now matches the trimmed-downcollection/v1interface.server/internal/domain/collection/service.go(modified)Service.AddDevicesToCollection/Service.RemoveDevicesFromCollection. AddedService.AddDevicesToGroup/Service.RemoveDevicesFromGroupwith inlineGetCollection+ type guard (rejects non-GROUP). AddedNewlyAssignedCounttoAssignDevicesToRackResult(Go-only, no proto change) so re-import callers can read newly-inserted count instead of final-membership count.NewlyAssignedCountsemantics matching pre-PRAddedCount.server/internal/domain/collection/service_test.go(modified)NewlyAssignedCountexposure.server/internal/domain/deviceresolver/resolver.go(modified)ValidateDeviceIdentifiershelper withMaxDeviceIdentifiers=10000andMaxDeviceIdentifierLen=256. Wired intoresolveExplicitDevicesso every consumer of the device-selector resolver inherits the validation.repeated stringproto rules; now lives in code sinceDeviceSelector.device_listdoesn't carry those constraints.server/internal/domain/fleetimport/importer.go(modified)CollectionManagerinterface swapped from genericAddDevicesToCollectiontoAddDevicesToGroup+AssignDevicesToRack. Four call sites migrated (existing-group, group create-race, existing-rack, rack create-race).devicesAssignedaccumulator now usesNewlyAssignedCountfor rack adds so idempotent re-imports don't overstate.server/internal/domain/fleetimport/importer_rack_assign_test.go(new)TestImporter_RackReimport_UsesNewlyAssignedCount— fakeCollectionManagerreturnsAssignedCount=3, NewlyAssignedCount=1; assertion confirms only the newly-assigned count flows todevicesAssigned.server/internal/domain/foremanimport/service_test.go(modified)fakeCollectionManagerto match the newCollectionManagerinterface (AddDevicesToGroup,AssignDevicesToRack).client/src/protoFleet/api/useDeviceSets.ts(modified)addDevicesToDeviceSet/removeDevicesFromDeviceSethooks. AddedaddDevicesToGroup/removeDevicesFromGrouphooks mirroringassignDevicesToRack(signal support, error handling, bigint counts).assignDevicesToRackhook now constructsDeviceSelector { deviceList: { identifiers: [...] } }internally so callers still passstring[].client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.tsx(modified)assignDevicesToRack({ targetRackId, deviceIdentifiers: [minerId] }).client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.test.tsx(modified)addDevicesToDeviceSettoassignDevicesToRack.client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionModalStack.tsx(modified)addDevicesToGroup({ targetGroupId, deviceIdentifiers, allDevices }).5. Key technical decisions & trade-offs
InvalidArgumentat the wire boundary, not after the server has set up a transaction. Alternative — keep the generic surface and add a feature flag — was rejected because it preserves the bypass path that PRs refactor(reparent): atomic reparent foundation (1/4) #463/feat(sites): atomic cross-site reparent (2/4) #464 worked to close.AssignDevicesToRackover reserving the old tag and addingdevice_selectorat tag 3. Accepted because the RPC isn't live for external consumers and client+server deploy atomically; the blast radius is bounded to the deploy-window stale-bundle case. If this becomes a public API later, markreserved 2;and migrate.NewlyAssignedCountadded as a Go-only field on the service result struct over extending the proto response. Importer's the only caller that cares about the distinction today; adding a wire field would also require client work that no UI surfaces consume. If a second caller needs it, promote to proto.AssignDevicesToRack) because rack membership is one-per-miner. The atomic replace doesn't apply when there's no prior-state conflict to close.AddDevicesToCollection/RemoveDevicesFromCollectionretained even though the wire and service methods are gone. These are shared infrastructure:Service.AddDevicesToGroup,Service.AssignDevicesToRack, andfleetimportall use them. Renaming for cosmetic alignment was rejected as out-of-scope churn.DeviceSelector.device_listdoesn't carry the per-item rules the oldrepeated string device_identifiershad (min_len,max_len,max_items).ValidateDeviceIdentifiersis called at the resolver layer so every device-selector consumer inherits the caps. Alternative — add the rules tocommon.v1.DeviceIdentifierList— would touch a shared message and require coordinating with other consumers; deferred.all_devicesselector variant rejected onAssignDevicesToRackonly, not on the new group RPCs. Bulk-adding every paired device to a single rack is never the intended operation; bulk-adding to a group is. Per the security review, this is bounded by the org-scoped resolver —all_devicesonAddDevicesToGroupenumerates only the caller's own org devices.PermRackManagereused for group RPCs, not a newPermGroupManage. The catalog doesn't currently distinguish the two; matching the legacy pair's permission preserves the pre-PR posture. A separatePermGroupManageis filed as a follow-up if groups gain reach beyond a rack-manager's intended blast radius.6. Testing & validation
Handler-layer tests (
server/internal/handlers/deviceset/handler_test.go):TestAddDevicesToGroup_HappyPath,_RejectsRackTarget,_PermissionRequired,_RejectsCrossOrgTarget— new group RPC coverage.TestRemoveDevicesFromGroup_HappyPath,_RejectsRackTarget,_PermissionRequired— mirror.TestAssignDevicesToRack_RejectsAllDevicesSelector,_RejectsEmptyIdentifier,_RejectsOverlongIdentifier,_RejectsOversizedList— selector + validation boundary cases.TestAssignDevicesToRack_HappyPathAssigns/_UnassignBranchupdated to use the newDeviceSelectorshape.Service-layer tests (
server/internal/domain/collection/service_test.go):AddDevicesToGroup/RemoveDevicesFromGrouphappy path + type guard + collection-not-found.AssignDevicesToRackupdated to verifyNewlyAssignedCountreflects only newly-inserted rows.fleetimport tests (
server/internal/domain/fleetimport/importer_rack_assign_test.go):TestImporter_RackReimport_UsesNewlyAssignedCount— fakeCollectionManagerreturns mismatchedAssignedCountvsNewlyAssignedCount; asserts the importer uses the latter.Client tests:
RackOverviewPage.test.tsxupdated mock fromaddDevicesToDeviceSettoassignDevicesToRack.Verification:
go build ./...clean.go test ./server/internal/handlers/deviceset/... ./server/internal/domain/collection/... ./server/internal/domain/fleetimport/... ./server/internal/domain/deviceresolver/... ./server/internal/handlers/middleware/...— all pass.npx tsc --noEmit(client) — clean.npm run lint— clean.golangci-lint run -c server/.golangci.yaml ./internal/domain/fleetimport/...— clean.Explicitly NOT covered:
GetCollection(orgID, ...)org-scoped predicate end-to-end.addDevicesToCollection/removeDevicesFromCollectionremoval regression is verified by the build (no remaining Go callers; client grep clean for the four removed identifiers). No explicit "wire-RPC is gone" test, since GitHub Actions' build + targeted tests catch missed references.Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com