feat(reparent): complete miner/rack reparent matrix + cascade device.building_id#489
feat(reparent): complete miner/rack reparent matrix + cascade device.building_id#489flesher wants to merge 12 commits into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dacf011072
ℹ️ 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".
🔐 Codex Security Review
Review SummaryOverall Risk: HIGH Findings[HIGH] AssignDevicesToBuilding Uses Org-Scoped Auth For Site-Scoped Mutation
[MEDIUM] Direct Building Assignments Are Invisible To Building-Scoped Miner Reads
NotesNo cryptostealing, pool hijack, shell execution, Nmap/discovery, plugin, Docker/Nginx, JWT, or protobuf wire-format issues were apparent in the reviewed diff. Generated by Codex Security Review | |
There was a problem hiding this comment.
Pull request overview
Extends the fleet hierarchy “reparent” surface to support assigning miners directly to buildings via a new device.building_id denormalized column and AssignDevicesToBuilding RPC, and ensures device.building_id stays consistent across rack/site/building reparent operations (mirroring existing device.site_id cascades). Also adds ProtoFleet UI actions to reparent racks to sites/buildings and miners to buildings, including confirmation dialogs for destructive cross-scope moves.
Changes:
- Add
device.building_id(migration 000090) and implementBuildingService.AssignDevicesToBuildingwith conflict detection + optional rack-membership force-clear. - Add SQL + store-layer cascades to keep
device.building_idin sync for rack-affecting writes (assign/move/delete/save/update membership). - Add ProtoFleet UI actions for “Add to building” (miners) and “Add to site/building” (racks), including a cross-site building-clear confirmation dialog.
Reviewed changes
Copilot reviewed 28 out of 38 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| server/sqlc/queries/device_set.sql | Adds building-id cascade/unassign queries for rack membership operations |
| server/sqlc/queries/building.sql | Adds AssignDevicesToBuilding + conflict lookup + building→site cascade query |
| server/migrations/000090_add_building_id_to_device.up.sql | Adds device.building_id FK + index + backfill from rack membership |
| server/migrations/000090_add_building_id_to_device.down.sql | Rolls back the device.building_id column + FK + index |
| server/internal/handlers/sites/handler_test.go | Updates site handler tests to expect building cascade call |
| server/internal/handlers/buildings/translate.go | Adds request/response translation helpers for new RPC conflict shape |
| server/internal/handlers/buildings/handler.go | Adds AssignDevicesToBuilding handler + permission gating |
| server/internal/handlers/buildings/handler_test.go | Adds/updates building handler tests to cover building cascades |
| server/internal/domain/stores/sqlstores/collection.go | Implements building cascade/unassign store methods for racks |
| server/internal/domain/stores/sqlstores/building.go | Implements building-store methods for assign + conflicts + site cascade |
| server/internal/domain/stores/interfaces/mocks/mock_collection_store.go | Updates mocks for new collection-store building cascade methods |
| server/internal/domain/stores/interfaces/mocks/mock_building_store.go | Updates mocks for new building-store methods |
| server/internal/domain/stores/interfaces/collection.go | Extends CollectionStore interface with building cascade/unassign methods |
| server/internal/domain/stores/interfaces/building.go | Extends BuildingStore interface with assign/conflict/site-cascade methods |
| server/internal/domain/sites/service.go | Adds device.building_id cascade when racks move across sites |
| server/internal/domain/sites/service_test.go | Adds expectations for the new building cascade behavior |
| server/internal/domain/collection/service.go | Adds building cascades to collection updates, deletes, rack assigns, and SaveRack |
| server/internal/domain/collection/service_test.go | Updates tests to assert building cascades fire appropriately |
| server/internal/domain/buildings/service.go | Implements AssignDevicesToBuilding service flow + adds building cascade in AssignRacksToBuilding |
| server/internal/domain/buildings/service_test.go | Adds tests for AssignDevicesToBuilding and same-site building cascades |
| server/internal/domain/buildings/models/models.go | Adds domain models for per-device building conflicts + request/result shapes |
| server/generated/sqlc/models.go | Generated (sqlc): adds Device.BuildingID field |
| server/generated/sqlc/device.sql.go | Generated (sqlc): selects/scans building_id in device queries |
| server/generated/sqlc/device_set.sql.go | Generated (sqlc): adds new building cascade/unassign query methods |
| server/generated/sqlc/db.go | Generated (sqlc): prepares/closes new statements |
| server/generated/sqlc/building.sql.go | Generated (sqlc): adds new building queries + helpers |
| server/generated/grpc/buildings/v1/buildingsv1connect/buildings.connect.go | Generated (buf/connect): wires new AssignDevicesToBuilding RPC |
| proto/buildings/v1/buildings.proto | Adds AssignDevicesToBuilding RPC + conflict response types |
| client/src/protoFleet/features/fleetManagement/pages/RacksPage.tsx | Adds rack “Add to site/building” actions (row + bulk) with confirmation dialog |
| client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu.tsx | Adds “Add to building” miner action + messaging updates |
| client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerReparentPicker.tsx | Adds building reparent flow with conflict-driven confirmation dialog |
| client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionsMenu.tsx | Adds bulk “Add to building” miner action + messaging updates |
| client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionsMenu.test.tsx | Updates action ordering expectations to include building action |
| client/src/protoFleet/features/fleetManagement/components/FleetGroupActionsMenu/FleetGroupListActionBar.tsx | Adds bulkExtraActions hook to inject bulk-only actions into menus |
| client/src/protoFleet/features/fleetManagement/components/FleetGroupActionsMenu/FleetGroupActionsMenu.tsx | Supports distinct extra-actions rendering for row vs bulk presentations |
| client/src/protoFleet/api/generated/buildings/v1/buildings_pb.ts | Generated (buf TS): new RPC/messages/enums for AssignDevicesToBuilding |
| client/src/protoFleet/api/buildings.ts | Adds assignDevicesToBuilding API wrapper + conflict mapping |
Files not reviewed (2)
- server/internal/domain/stores/interfaces/mocks/mock_building_store.go: Generated file
- server/internal/domain/stores/interfaces/mocks/mock_collection_store.go: Generated file
|
Filed follow-up #492 for the rack→site server-driven preview discussed in review — keeping the current client-side detection here to keep this PR focused on the column + cascade work. |
…building_id Adds the missing "Add to building" action for miners (new AssignDevicesToBuilding RPC + device.building_id column with backfill), the missing rack-list "Add to site" actions (row + bulk), and parallel building-id cascade across every rack-affecting flow so the new column stays in lockstep with rack membership. Adds a cross-site building-clear confirmation dialog on rack→site moves to match the cross-building miner-reparent confirm shape. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Migration 000090 collided with main's 000090_allow_mqtt_reassert; renumber building_id migration to 000091. - MinerReparentPicker: only open the cross-building confirm when all conflicts are DEVICE_IN_RACK_AT_OTHER_BUILDING (clearable). Mixed/DEVICE_NOT_FOUND surfaces as an error toast since force-clear wouldn't unblock those. - Register AssignDevicesToBuilding in ProcedurePermissions so the RBAC contract test recognizes the new RPC. - Update stale dedupeStrings comment (sorting happens after dedup). - Fix FindDeviceBuildingConflicts comment — predicate filters out rows, doesn't return NULL. - Re-run buf generate + prettier on top of rebased main. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
dacf011 to
96c64a1
Compare
|
Rebased on main + addressed PR feedback in 96c64a1:
The Force-pushed after rebase. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 96c64a1448
ℹ️ 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".
Six cascade-invariant bugs in the new device.building_id work, all in the same family — keep device.building_id and device.site_id in lockstep with their owning building/site across every lifecycle path: - AssignDevicesToBuilding now cascades device.site_id even when the target building is site-less (cascades to NULL), so building/site don't disagree immediately after the move (Codex MEDIUM). - Cross-site rack-without-building detection: new DEVICE_IN_RACK_AT_OTHER_SITE conflict reason fires when the rack's site_id differs from the target building's site, even if the rack has no building. Force-clear treats it identically to the building variant (Codex HIGH #1). - SaveRack building-clear now cascades device.building_id = NULL when the rack transitions from having a building to none. Plumbed buildingChanged through replaceRackMembershipAndSlots so the cascade fires on the clear transition without nuking direct AssignDevicesToBuilding assignments on untouched racks (inline review). - DeleteBuilding clears direct-FK device.building_id rows in the same soft-delete tx so devices can't outlive the building they reference. FK ON DELETE only fires on hard delete (Codex HIGH #3a). - DeleteSite clears direct-FK device.building_id rows BEFORE buildings get cascade-soft-deleted, mirroring the existing rack unassign step (Codex HIGH #3c). - AssignBuildingsToSite now also cascades device.site_id for devices joined to moved buildings via direct-FK (in addition to the existing rack-path cascade). Aggregate device count in the response covers both (Codex HIGH #3b). Plus client-side max_items preflight for AssignDevicesToBuilding so all-mode selections over 10k surface a specific message instead of a generic server-validation failure (Codex MEDIUM #5). Codex HIGH #2 (building filter doesn't read device.building_id) stays deferred via #493 — already in this PR's non-goals. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Security review triage (5f5824a)Triaged the Codex Security Review findings:
Plus the inline Copilot/Codex comments from the previous round — also fixed in 96c64a1 + 5f5824a (cross-building dialog only fires for clearable conflicts; ProcedurePermissions registered; stale comments rewritten; SaveRack building-clear cascade wired). All review threads now resolved. CI re-running on 5f5824a. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5f5824a835
ℹ️ 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".
- buildings.pb.go / buildings.connect.go: apply the goimports import grouping that CI's `just gen` produces (stdlib block then third-party). My local buf-generate-only run skipped the goimports pass, so the two edited grpc files carried raw buf ordering and failed the generated-code drift check. - deviceset handler test: AssignDevicesToRack now fires CascadeAddedDeviceBuildings after CascadeAddedDeviceSites; add the matching mock expectation to the InOrder block. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ee642802cd
ℹ️ 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".
Codex P2: adding a device to a rack in an unassigned building (building_id set, site_id NULL) stamped device.building_id but left device.site_id at the device's old site — a contradiction (device claims a site while its building has none). Same class as the AssignDevicesToBuilding site-less fix, in the rack-add paths. The site cascade now fires whenever the rack has a site OR a building, cascading device.site_id to the rack's site (NULL for an unassigned building) so it can't disagree with the building_id stamp. Fully- unassigned racks (no site, no building) still skip the cascade to preserve direct device.site_id assignments. - CascadeAddedDeviceSites query: guard widened to (site_id IS NOT NULL OR building_id IS NOT NULL) so it nulls device.site_id for site-less-building racks instead of no-opping. - AssignDevicesToRack: capture rack building_id; fire the site cascade when site OR building is set. - UpdateCollection rack branch: same gate widening (CascadeRackDeviceSites is already NULL-capable). - SaveRack replaceRackMembershipAndSlots: cascadeFires now includes finalBuildingID != nil so adding to an already-site-less-building rack nulls the new members' site. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ecef2adaed
ℹ️ 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".
Both are the same invariant (device.building_id must agree with device.site_id and the hierarchy), at two entry points not covered by the earlier passes: - AssignDevicesToSite (sites/service.go): a direct site move only wrote device.site_id, leaving a device with a direct-FK device.building_id pointing at a building in the OLD site. New ClearDeviceBuildingsOnSiteMismatch nulls building_id for any moved device whose building isn't in the new target site (keeps it when the building IS in the target; untouched when the device has no building). Runs in the same tx after the site write. - CreateCollection (collection/service.go): creating a rack already in a building with initial members cascaded device.site_id but not device.building_id, leaving initial members' building_id stale. Added the building cascade and widened the site-cascade gate to fire for site-less buildings (cascades site to NULL), matching the add-to-existing-rack path. buildingStore is now a hard dependency of AssignDevicesToSite (dropped the nil-guard for consistency with DeleteSite/AssignBuildingsToSite); wired the building-store mock + expectations into the affected sites service + handler tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…uilding Codex P2: AssignDevicesToBuilding skipped the cross-site rack probe when the target building was site-less (targetSiteID nil). A device in a rack at a real Site A could then be assigned to the unassigned building with no conflict — the site cascade would null device.site_id while the device stayed a member of the Site-A rack. The probe now runs whenever target_building_id is set (the case where the building write cascades site), comparing the rack's site against the target's (nil included). FindDeviceSiteConflicts only returns racks with a non-null site, so a site-less target flags every such device as DEVICE_IN_RACK_AT_OTHER_SITE — already a force-clearable reason, so the existing confirm/force-clear path removes the rack membership before the move. Added TestAssignDevicesToBuilding_siteLessBuildingFlagsRackAtRealSite. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1b688a2931
ℹ️ 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".
…sing Codex P2: summarizeBuildingClearance silently skipped any rack whose building_id couldn't be resolved in buildingById — which is empty while the one-shot listAllBuildings is loading or after it fails. That made the function return null and the Add-to-site flow dispatch AssignRacksToSite WITHOUT the confirm dialog, even though the server still clears rack.building_id on a cross-site move. The confirmation guard silently no-op'd exactly when metadata was unavailable. Now an unresolvable building counts toward the prompt (tracked via an `unresolved` flag) instead of being skipped, so the dialog fires conservatively. When any affected rack is unresolved, the dialog renders a generic "may belong to a different site" message rather than naming a partial building set. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e5c240905a
ℹ️ 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".
Outcome-level integration test that drives every reparent service path (AssignDevicesToRack/Building/Site, AssignRacksToBuilding/Site, AssignBuildingsToSite, DeleteBuilding, DeleteSite) against a real DB and, after each, asserts no live device is left in an inconsistent site/building state: 1. building set → its site == device.site_id (NULL == NULL ok) 2. racked device → site/building match the rack's Unlike the service-layer mock tests (which assert a specific cascade was called), this asserts the result, so it catches a path that wires a cascade wrong or forgets one — the class of bug surfaced repeatedly in review. It's implementation-agnostic, so it stays the regression guard after the #495 choke-point refactor and #492 server-driven preview land. Runs in CI (Postgres-backed); skips under -short like the other *_integration_test.go files, so it isn't exercised locally without a DB. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 23db0b9899
ℹ️ 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".
Codex P2: FleetGroupActionsMenu picked the extra-action set by scopes.length (>1 → bulkExtraActions, else extraActions). The bulk action bar opened with exactly one checkbox-selected rack is presentation="bulk" but scopes.length==1, so it fell back to the empty extraActions and the new bulk "Add to building"/"Add to site" actions disappeared for a single selection. Key on presentation instead, which is the real row-vs-bulk discriminator. Updated the multi-scope test to pass presentation="bulk" (how multi-scope actually renders) so it still pins that row-only extras stay hidden in the bulk bar. The companion finding — racks moved cross-site vanishing from the /racks?site= view because the filter expands to building IDs only — is a pre-existing rack-list filter limitation (no native site filter on ListDeviceSets) that this action surfaces. Tracked as follow-up #497; deferred for the same read-side-surfacing reasons as #493. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both close the building-less-rack corner of the device site/building lockstep invariant. The precise rule they settle: a rack dictates its members' placement only when it HAS a placement (a site or a building); a site-level rack (site set, building NULL) is a real placement that forces members' building to NULL, while a fully-unassigned rack dictates nothing. - CascadeAddedDeviceBuildings (device_set.sql): fired only when the rack had a building, so adding a device with a direct building_id to a site-level rack left the stale building pointer while the rack had none. Now fires whenever the rack has a placement (site OR building), clearing device.building_id to match a site-level rack. - AssignDevicesToBuilding conflict detection: a device in a site-level rack assigned to a building in the SAME site was flagged by neither the building probe (rack building NULL) nor the site probe (same site), so it got a direct building while staying in a building-less rack. New FindDevicesInBuildingLessPlacedRacks flags those as a clearable IN_RACK_AT_OTHER_BUILDING conflict; fully-unassigned racks are excluded. Tightened the device-placement invariant integration test's rack clause to the same rule (only placed racks constrain members), and added TestAssignDevicesToBuilding_flagsBuildingLessPlacedRack. The invariant test would have caught both findings on its own. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c56ad90308
ℹ️ 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".
Codex P2 (fresh on c56ad90): replaceRackMembershipAndSlots gated the building cascade on finalBuildingID != nil || buildingChanged, so a site-level rack (site set, building NULL, no building transition) skipped it. A newly added member with a stale direct device.building_id kept that building while the rack's building_id was NULL — violating the placed-rack lockstep invariant. Gate now fires whenever the rack has a placement (site OR building) or its building transitioned, mirroring CascadeAddedDeviceBuildings' placement gate; CascadeRackDeviceBuildings(nil) clears members' building for a site-level rack. Fully-unassigned racks still skip it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Summary
Closes the remaining gaps in the rack/miner reparent matrix on top of the atomic-reparent foundation shipped by PRs #463 (foundation), #464 (cross-site atomic), #466 (group RPCs), and #468 (bulk SQL + concurrency hardening). Operators can now move miners directly into a building (new bulk + single-miner action), move racks directly to a different site from the rack list (row + bulk), and get a confirmation dialog before any rack reparent silently clears
rack.building_id. Under the hood, this PR also introducesdevice.building_idas a peer todevice.site_idand keeps it in lockstep with rack membership across every reparent path.How it works
Miner → building (
AssignDevicesToBuilding). The new RPC onBuildingServicefollows the exact transactional shapeAssignDevicesToSiteestablished in PR #464: lock the target building, row-lock the devices in sorted-identifier order, run a per-device conflict check (rack at a different building), and either returnPerDeviceBuildingConflict[]with zero writes or apply the batch atomically. The atomic write setsdevice.building_id, then cascadesdevice.site_idto the target building's site so the two columns stay aligned. An optionalforce_clear_conflicting_rack_membershipflag — gated byrack:managepermission — drops conflicting rack memberships in the same transaction so cross-building moves don't strand miners.The miner-list client (
MinerReparentPicker) drives this with a two-step UI: first call without force; if the response carries conflicts, raise a "Move miners between buildings?" dialog; on Continue, re-dispatch withforce=true. Same shape as the cross-site dialog already in place.Rack → site, with building-clear confirm. The rack list now exposes
assignRacksToSite(shipped earlier) as a row action and a bulk action via a newbulkExtraActionsslot onFleetGroupActionsMenu. Before dispatching, the client checks each selected rack's current building against the target site using already-loadedallBuildings; if any rack would have itsbuilding_idsilently cleared (server behavior on a cross-site move), a "Move racks between sites?" dialog fires with the affected building labels. No new RPC — purely a client-side parity fix with the cross-building miner confirm.device.building_id cascade. Migration
000091adds the column withON DELETE SET NULLand backfills it from existing rack membership in the same transaction so the new column ships in a consistent state. Four new sqlc queries (UnassignDeviceBuildingsByRack,CascadeRackDeviceBuildings[Bulk],CascadeAddedDeviceBuildings) mirror the existingsite_idcascade family. They're wired into every rack-affecting write path so a rack reparent or membership change keeps member devices'building_idin step:AssignRacksToBuildingCascadeRackDeviceBuildingsBulk(rackIDs, targetBuildingID)— independent of the site cascade, so a same-site building move still propagates.AssignRacksToSiterack.building_idCascadeRackDeviceBuildingsBulk(rackIDs, nil)for the same rack set.AssignDevicesToRackCascadeAddedDeviceBuildingsso new members inherit the rack's building.DeleteCollection(rack)UnassignDeviceBuildingsByRack— clears member building only where it still matches the rack's stamped building.UpdateCollection(rack),SaveRackCascadeRackDeviceBuildings(rackID, rack.BuildingID).Each cascade query uses
IS DISTINCT FROMto skip no-op rows and preserves directAssignDevicesToBuildingassignments that have diverged from the rack.Diagrams
sequenceDiagram autonumber participant UI as "Miner list / Single miner menu" participant Picker as "MinerReparentPicker (kind=building)" participant Svc as "BuildingService.AssignDevicesToBuilding" participant DB as "Postgres (single tx)" UI->>Picker: "Add to building" Picker->>Svc: "target_building_id, ids, force=false" Svc->>DB: "lock building, lock devices, find conflicts" alt "device in rack at other building" Svc-->>Picker: "conflicts[], no writes" Picker->>UI: Move-miners-between-buildings dialog UI->>Picker: "Continue" Picker->>Svc: "target_building_id, ids, force=true" Svc->>DB: "RemoveDevicesFromAnyRack(conflicting ids)" end Svc->>DB: "UPDATE device SET building_id = target" Svc->>DB: "CASCADE device.site_id to target building site" Svc-->>Picker: "reassigned_count, site_reassigned_device_count" Picker->>UI: "toast + refetch"flowchart TD A["Rack list: Add to site (row or bulk)"] --> B{"Any rack's current<br/>building belongs to<br/>a different site?"} B -- no --> C["AssignRacksToSite"] B -- yes --> D[Move-racks-between-sites confirm] D -- Cancel --> E["Close picker, no write"] D -- Continue --> C C --> F["Server: update rack.site_id,<br/>clear rack.building_id,<br/>cascade device.site_id<br/>and device.building_id"]flowchart LR subgraph "Reparent write paths" A1["AssignRacksToBuilding"] A2["AssignRacksToSite"] A3["AssignDevicesToRack"] A4["DeleteCollection (rack)"] A5["UpdateCollection / SaveRack"] end subgraph "Cascade family (sqlc)" C1["CascadeRackDeviceBuildingsBulk"] C2["CascadeAddedDeviceBuildings"] C3["UnassignDeviceBuildingsByRack"] C4["CascadeRackDeviceBuildings"] end A1 --> C1 A2 --> C1 A3 --> C2 A4 --> C3 A5 --> C4 C1 --> D[(device.building_id)] C2 --> D C3 --> D C4 --> DAreas of the code involved
proto/buildings/v1/buildings.protoAssignDevicesToBuildingRPC + request/response +PerDeviceBuildingConflictenum.max_items: 10000, optional target) and the conflict-reason enum.server/generated/**,client/.../generated/**server/migrations/000091_*.{up,down}.sqldevice.building_idcolumn with FK + index + backfill.ON DELETE SET NULLand the backfill filters soft-deleted rows.server/sqlc/queries/building.sqlAssignDevicesToBuilding,CascadeDevicesSiteForBuilding,FindDeviceBuildingConflicts,GetBuildingSiteID.IS DISTINCT FROMfor no-op skip.server/sqlc/queries/device_set.sqlUnassignDeviceBuildingsByRack,CascadeRackDeviceBuildings[Bulk],CascadeAddedDeviceBuildings.*Sites*queries one-for-one.server/internal/domain/buildings/{service,models}.goAssignDevicesToBuildingservice method,PerDeviceBuildingConflictdomain type,cascadeBuildingRackIDstracking inAssignRacksToBuilding.server/internal/domain/sites/service.goAssignRacksToSitenow firesCascadeRackDeviceBuildingsBulkafter the site cascade.server/internal/domain/collection/service.goAssignDevicesToRack,DeleteCollectionrack branch,UpdateCollectionrack branch,SaveRack(replaceRackMembershipAndSlots) now all call the building-cascade peer.server/internal/domain/stores/{interfaces,sqlstores}/{building,collection}.go+ mocksserver/internal/handlers/buildings/{handler,translate}.goAssignDevicesToBuildinghandler + translators.site:managealways, plusrack:managewhenforce_clear=true. MirrorsAssignDevicesToSite.client/src/protoFleet/api/buildings.tsuseBuildings().assignDevicesToBuildinghook that surfaces conflicts viaonError(msg, conflicts).useSites().assignDevicesToSiteshape.client/.../MinerActionsMenu/MinerReparentPicker.tsxkind="building"branch + cross-building confirm dialog + force-retry.client/.../MinerActionsMenu/{MinerActionsMenu,SingleMinerActionsMenu}.tsxclient/.../FleetGroupActionsMenu/{FleetGroupActionsMenu,FleetGroupListActionBar}.tsxbulkExtraActionsprop slot for multi-select reparent actions.scopes.length > 1filter that previously dropped extras entirely.client/.../pages/RacksPage.tsxdispatchRackSiteAssignhelper +summarizeBuildingClearance+ "Move racks between sites?" dialog.*_test.go,*.test.tsxTestAssignRacksToBuilding_sameSiteCascadesBuilding).Key technical decisions & trade-offs
device.building_idas a denormalized peer todevice.site_id(Option A) rather than routing miner→building through a rack (Option B) or auto-picking a rack (Option C). Option A makes "miner directly in a building" a representable state and gives the new action the same operator UX as Add-to-site; the cost is a denormalization that must be cascaded everywhere, which this PR pays in full.PerDeviceBuildingConflict[]and the client retries withforce=true— same shape asAssignDevicesToSite. The rack→site confirm uses already-loaded building data to detect clearance client-side instead of adding a probe RPC; both flows render the same dialog shape.device.building_idpopulated from their rack'sbuilding_idin the same transaction as the column add, so the cascade has a consistent starting state from the moment the column exists. Trade-off: the migration runs anUPDATE devicethat scales with rack-membership count; acceptable at current fleet sizes.IS DISTINCT FROMand "match-only" predicates. When unassigning on rack delete,UnassignDeviceBuildingsByRackonly nulls devices whosebuilding_idstill matched the rack's — operators who usedAssignDevicesToBuildingto override don't lose their assignment when the rack goes away. Mirrors the existing site behavior.bulkExtraActionsprop onFleetGroupActionsMenurather than reusingextraActions(which is hidden when more than one scope is selected). Keeps the row-vs-bulk semantics explicit; the existing extras ("View rack", "Edit rack") wouldn't make sense in bulk.target_building_id == 0;nilis the only sentinel for Unassigned. MatchesAssignDevicesToSiteto avoid the "I forgot to populate the int" ambiguity.Testing & validation
just lint— clean (eslint, golangci-lint, buf lint).domain/buildings,handlers/buildings,domain/sites,handlers/sites,domain/collection— all pass. New:TestAssignDevicesToBuilding_{writesAndCascadesOnSuccess,rejectsCrossBuildingConflict,unassignedTargetSkipsLockAndCascade,forceClearCascadesRackMembership,rejectsEmptyIdentifiers}+TestAssignRacksToBuilding_sameSiteCascadesBuilding(pins the same-site building cascade gap the site cascade alone left). Existing tests updated to expect the new cascade calls.vitest— 986 passed, 1 skipped;tsc --noEmitclean.buf generate,sqlc generate,mockgen) regenerated and verified in sync.sqlstoresintegration tests (need local Postgres — pre-existing failures unrelated to this diff); E2E (slow, docker-compose).Pre-merge smoke test (manual)
Recommended click-through since E2E wasn't run:
devices.reassigned_to_buildingevents with correct metadata (target_building_id, device_count, site_reassigned_device_count, optional force_cleared_* keys).Deferred / out of scope
GetBuildingStats, miner-list "building" filter) still resolve through rack membership. The new column makes "unracked miners directly in a building" representable, but no list view / count / filter surfaces it yet — deferred to the BuildingPage refresh.allBuildings; the server still cascades silently withclearedBuildingCountin the response.