Skip to content

feat(reparent): complete miner/rack reparent matrix + cascade device.building_id#489

Open
flesher wants to merge 12 commits into
mainfrom
add-reparenting-actions-fleet-lists
Open

feat(reparent): complete miner/rack reparent matrix + cascade device.building_id#489
flesher wants to merge 12 commits into
mainfrom
add-reparenting-actions-fleet-lists

Conversation

@flesher

@flesher flesher commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

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 introduces device.building_id as a peer to device.site_id and keeps it in lockstep with rack membership across every reparent path.

How it works

Miner → building (AssignDevicesToBuilding). The new RPC on BuildingService follows the exact transactional shape AssignDevicesToSite established 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 return PerDeviceBuildingConflict[] with zero writes or apply the batch atomically. The atomic write sets device.building_id, then cascades device.site_id to the target building's site so the two columns stay aligned. An optional force_clear_conflicting_rack_membership flag — gated by rack:manage permission — 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 with force=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 new bulkExtraActions slot on FleetGroupActionsMenu. Before dispatching, the client checks each selected rack's current building against the target site using already-loaded allBuildings; if any rack would have its building_id silently 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 000091 adds the column with ON DELETE SET NULL and 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 existing site_id cascade family. They're wired into every rack-affecting write path so a rack reparent or membership change keeps member devices' building_id in step:

Path Trigger Cascade
AssignRacksToBuilding Rack moves to a different building CascadeRackDeviceBuildingsBulk(rackIDs, targetBuildingID) — independent of the site cascade, so a same-site building move still propagates.
AssignRacksToSite Cross-site move clears rack.building_id CascadeRackDeviceBuildingsBulk(rackIDs, nil) for the same rack set.
AssignDevicesToRack Devices added to a rack CascadeAddedDeviceBuildings so new members inherit the rack's building.
DeleteCollection (rack) Rack soft-delete UnassignDeviceBuildingsByRack — clears member building only where it still matches the rack's stamped building.
UpdateCollection (rack), SaveRack Membership replace on a rack with a building CascadeRackDeviceBuildings(rackID, rack.BuildingID).

Each cascade query uses IS DISTINCT FROM to skip no-op rows and preserves direct AssignDevicesToBuilding assignments 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"
Loading
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"]
Loading
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 --> D
Loading

Areas of the code involved

Area / package / file What changed Why it matters for review
proto/buildings/v1/buildings.proto New AssignDevicesToBuilding RPC + request/response + PerDeviceBuildingConflict enum. The public contract for the new action; reviewers should sanity-check validators (max_items: 10000, optional target) and the conflict-reason enum.
server/generated/**, client/.../generated/** Regenerated proto + sqlc. Generated — skip.
server/migrations/000091_*.{up,down}.sql New device.building_id column with FK + index + backfill. Schema change. Backfill seeds the column from existing rack membership; reviewer should verify the FK uses ON DELETE SET NULL and the backfill filters soft-deleted rows.
server/sqlc/queries/building.sql New AssignDevicesToBuilding, CascadeDevicesSiteForBuilding, FindDeviceBuildingConflicts, GetBuildingSiteID. Site-cascade analogs for the building-id direction; all use IS DISTINCT FROM for no-op skip.
server/sqlc/queries/device_set.sql New UnassignDeviceBuildingsByRack, CascadeRackDeviceBuildings[Bulk], CascadeAddedDeviceBuildings. Cascade family mirrors the existing *Sites* queries one-for-one.
server/internal/domain/buildings/{service,models}.go New AssignDevicesToBuilding service method, PerDeviceBuildingConflict domain type, cascadeBuildingRackIDs tracking in AssignRacksToBuilding. Heart of the new RPC + the building-cascade addition to the existing flow. Reviewer should focus on lock ordering and the force-clear branch's residual-conflict check.
server/internal/domain/sites/service.go AssignRacksToSite now fires CascadeRackDeviceBuildingsBulk after the site cascade. Keeps cross-site rack moves clearing both columns.
server/internal/domain/collection/service.go AssignDevicesToRack, DeleteCollection rack branch, UpdateCollection rack branch, SaveRack (replaceRackMembershipAndSlots) now all call the building-cascade peer. The widest blast radius; reviewer should verify the cascade conditions match the existing site-cascade ones (skip on nil to preserve direct assignments).
server/internal/domain/stores/{interfaces,sqlstores}/{building,collection}.go + mocks New store methods + regenerated mocks. Thin wrappers; mock churn is mechanical.
server/internal/handlers/buildings/{handler,translate}.go New AssignDevicesToBuilding handler + translators. Permission gating: site:manage always, plus rack:manage when force_clear=true. Mirrors AssignDevicesToSite.
client/src/protoFleet/api/buildings.ts useBuildings().assignDevicesToBuilding hook that surfaces conflicts via onError(msg, conflicts). Matches the useSites().assignDevicesToSite shape.
client/.../MinerActionsMenu/MinerReparentPicker.tsx New kind="building" branch + cross-building confirm dialog + force-retry. Reviewer should confirm the dialog promise plumbing matches the cross-site path (settle on Cancel + Continue).
client/.../MinerActionsMenu/{MinerActionsMenu,SingleMinerActionsMenu}.tsx "Add to building" inserted between Add-to-site and Add-to-rack. Pure ordering + glue.
client/.../FleetGroupActionsMenu/{FleetGroupActionsMenu,FleetGroupListActionBar}.tsx New bulkExtraActions prop slot for multi-select reparent actions. Single API change; replaces a scopes.length > 1 filter that previously dropped extras entirely.
client/.../pages/RacksPage.tsx Row + bulk "Add to site" actions + dispatchRackSiteAssign helper + summarizeBuildingClearance + "Move racks between sites?" dialog. All rack→site dispatch paths now route through the helper so the confirm fires uniformly.
*_test.go, *.test.tsx New cascade test + updated mock expectations across affected packages. Mock churn from new store methods; one new positive test (TestAssignRacksToBuilding_sameSiteCascadesBuilding).

Key technical decisions & trade-offs

  • device.building_id as a denormalized peer to device.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.
  • Conflict UX: server-driven for miner→building, client-driven for rack→site. The new RPC returns PerDeviceBuildingConflict[] and the client retries with force=true — same shape as AssignDevicesToSite. 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.
  • Backfill in the migration, not a separate job. Existing rack members get device.building_id populated from their rack's building_id in 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 an UPDATE device that scales with rack-membership count; acceptable at current fleet sizes.
  • Cascade preserves direct assignments via IS DISTINCT FROM and "match-only" predicates. When unassigning on rack delete, UnassignDeviceBuildingsByRack only nulls devices whose building_id still matched the rack's — operators who used AssignDevicesToBuilding to override don't lose their assignment when the rack goes away. Mirrors the existing site behavior.
  • New bulkExtraActions prop on FleetGroupActionsMenu rather than reusing extraActions (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.
  • Explicit-target convention. The new RPC rejects an explicit target_building_id == 0; nil is the only sentinel for Unassigned. Matches AssignDevicesToSite to avoid the "I forgot to populate the int" ambiguity.

Testing & validation

  • just lint — clean (eslint, golangci-lint, buf lint).
  • Server unit tests in 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.
  • Client vitest — 986 passed, 1 skipped; tsc --noEmit clean.
  • Generated code (buf generate, sqlc generate, mockgen) regenerated and verified in sync.
  • Not run: sqlstores integration 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:

  • Move miners between buildings with mixed-site rack memberships → "Move miners between buildings?" dialog renders + Continue completes.
  • Bulk-move racks to a new site where some have buildings → "Move racks between sites?" dialog lists the right building labels.
  • Single-rack row "Add to site" → confirm dialog flow works identically to bulk.
  • Activity log shows devices.reassigned_to_building events with correct metadata (target_building_id, device_count, site_reassigned_device_count, optional force_cleared_* keys).

Deferred / out of scope

  • Building-scoped read consumers (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.
  • No proto-level awareness in the rack→site confirm. The check is purely client-side using already-loaded allBuildings; the server still cascades silently with clearedBuildingCount in the response.

Copilot AI review requested due to automatic review settings June 17, 2026 21:07
@flesher flesher requested a review from a team as a code owner June 17, 2026 21:07
@github-actions github-actions Bot added javascript Pull requests that update javascript code client server shared labels Jun 17, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread server/sqlc/queries/building.sql
Comment thread proto/buildings/v1/buildings.proto
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown

🔐 Codex Security Review

Note: This is an automated security-focused code review generated by Codex.
It should be used as a supplementary check alongside human review.
False positives are possible - use your judgment.

Scope summary

  • Reviewed pull request diff only (c01deacc27c4d97727ebe3e469abffab22fca142...c162c4acc1851b44ab1927ff1013d23cbdf7f5a8, exact PR three-dot diff)
  • Model: gpt-5.5

💡 Click "edited" above to see previous reviews for this PR.


Review Summary

Overall Risk: HIGH

Findings

[HIGH] AssignDevicesToBuilding Uses Org-Scoped Auth For Site-Scoped Mutation

  • Category: Auth
  • Location: server/internal/handlers/buildings/handler.go:131
  • Description: The new AssignDevicesToBuilding handler checks site:manage with an empty authz.ResourceContext{} before resolving the target building’s site. In this auth model, nil SiteID is an org-scoped action, so site-specific narrowing is not applied. The same pattern is used for the optional rack:manage check on line 140.
  • Impact: A user with a broad org grant that is intentionally narrowed at a site can still move devices into buildings in that narrowed site, cascade device.site_id, and with force_clear_conflicting_rack_membership, delete rack memberships without a site/rack-scoped authorization check.
  • Recommendation: Resolve the target building’s site_id before authorizing and call RequirePermission with authz.ResourceContext{SiteID: targetSiteID}. For force-clear, authorize against the affected rack/site contexts before deleting memberships. Add regression tests for org-scope grants narrowed by a site-specific assignment.

[MEDIUM] Direct Building Assignments Are Invisible To Building-Scoped Miner Reads

  • Category: Reliability
  • Location: server/internal/domain/buildings/service.go:757
  • Description: The new flow writes device.building_id directly, but existing building-scoped miner filtering still only matches rack membership via device_set_rack.building_id. GetBuildingStats uses that filter, so devices successfully assigned to a building without rack membership are not counted or returned in DeviceIdentifiers.
  • Impact: Operators can receive a successful “moved to building” result, but the building page, building-scoped miner lists, stats, telemetry rollups, and bulk actions can omit those miners. This makes the new direct-assignment feature behave inconsistently and can lead to missed operations or misleading fleet state.
  • Recommendation: Update the miner building filter to include device.building_id = ANY(...) in addition to rack-derived dsr.building_id, and define how include_no_building interacts with direct assignments. Add an integration test that calls AssignDevicesToBuilding for an unracked miner and verifies building-scoped miner queries and GetBuildingStats include it.

Notes

No 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 |
Triggered by: @flesher |
Review workflow run

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

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 implement BuildingService.AssignDevicesToBuilding with conflict detection + optional rack-membership force-clear.
  • Add SQL + store-layer cascades to keep device.building_id in 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

Comment thread server/internal/domain/buildings/service.go Outdated
Comment thread server/sqlc/queries/building.sql Outdated
@flesher

flesher commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

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>
@flesher flesher force-pushed the add-reparenting-actions-fleet-lists branch from dacf011 to 96c64a1 Compare June 17, 2026 22:32
@flesher

flesher commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on main + addressed PR feedback in 96c64a1:

The useCurtailmentPlanPreview > ignores an older initial preview after a newer refresh flake in CI isn't in my diff (unrelated test, no changes here); should clear on re-run.

Force-pushed after rebase.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread server/internal/domain/buildings/service.go Outdated
Comment thread server/migrations/000091_add_building_id_to_device.up.sql
Comment thread server/internal/domain/collection/service.go Outdated
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>
@flesher

flesher commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Security review triage (5f5824a)

Triaged the Codex Security Review findings:

Finding Disposition
HIGH #1 — building reassignment can corrupt rack/site invariants (cross-site rack with no building) Fixed. New DEVICE_IN_RACK_AT_OTHER_SITE conflict reason catches the case where a device's rack lives at a different site than the target building, even with no building on the rack. Force-clear treats it identically to the building variant.
HIGH #2 — direct building assignments invisible to building-scoped queries Deferred via #493 — matches the explicit non-goal already in the PR description.
HIGH #3a — direct device.building_id not cleared on building soft-delete Fixed. DeleteBuilding now calls ClearDeviceBuildingsByBuilding in the same tx.
HIGH #3b — AssignBuildingsToSite doesn't cascade device.site_id for direct-FK devices Fixed. New CascadeDirectDeviceSitesByBuildings runs alongside the rack-path cascade; the aggregate device count covers both.
HIGH #3c — DeleteSite doesn't clear direct device.building_id Fixed. DeleteSite clears device.building_id for the cascade-deleted buildings before they're soft-deleted.
MEDIUM #4 — site-less building leaves stale device.site_id Fixed. Cascade fires whenever target_building_id is set, including site-less targets (cascades to NULL).
MEDIUM #5 — building all-mode can exceed 10k cap client-side Fixed. Same preflight as the site path; specific error message instead of a generic server-validation failure.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread server/internal/domain/collection/service.go
- 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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread server/internal/domain/sites/service.go
Comment thread server/internal/domain/collection/service.go
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread server/internal/domain/buildings/service.go Outdated
flesher and others added 2 commits June 17, 2026 18:44
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread client/src/protoFleet/features/fleetManagement/pages/RacksPage.tsx Outdated
…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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread server/sqlc/queries/building.sql
Comment thread server/sqlc/queries/device_set.sql Outdated
Comment thread client/src/protoFleet/features/fleetManagement/pages/RacksPage.tsx
flesher and others added 2 commits June 18, 2026 08:44
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread server/internal/domain/collection/service.go Outdated
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

client javascript Pull requests that update javascript code server shared

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants