Conversation
Add resolved.go with typed resolvedSite/Building/Rack/Miner nodes and resolvePlan(), which collapses parsed rows and the live snapshot into one graph keyed by stable ID with parents linked as pointers. Identity, parent resolution, ID/name consistency errors, action classification, omission counts, and the scoped miner population are computed once here. Introduced unwired: no existing consumer reads the graph yet, so behavior is unchanged. Later steps route preview counts, validation, apply, and the commit token through it and remove the per-row desired*/count*/normalize* helpers it replaces. Includes the TDD plan doc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…/7, #767) buildPlan now builds the resolved plan and consumes it for omission counts and change summaries via computeChanges. Topology creates come from node action classification and deletes from omission counts (the same identity math), so the create/delete/omission counting no longer re-derives the desired set independently. Update and miner rename/move counts still use the row-comparison helpers; those migrate when miners are resolved in a later step. Removes the now-dead countCreates/countSiteCreates/countBuildingCreates/ countRackCreates/countDeletes and the rowSetFromSites/Racks/RackLabels helpers they used. Change-summary output is unchanged, verified by the existing suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…/7, #767) Add buildingmodels.GridCapacity and RackCapacityExceeded as the single home for the aisles × racks_per_aisle rack cap. The buildings-domain guards (AssignRacksToBuilding, the shrinking UpdateBuilding layout check) and sitemap's validateBuildingRackCapacity now share this invariant instead of each recomputing the arithmetic. RackCapacityExceeded takes a net-final resultingCount and is agnostic about how it was derived (import graph vs live reassignment), so the interactive reparent flow (#778) can reuse the same implementation rather than keeping a third copy. Behavior is unchanged; verified by the sitemap and buildings suites plus new buildingmodels unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…plan (#767) resolvePlan now resolves MINER rows into resolvedMiner nodes matched to their live miner by device identifier, with rename detection. validateKnownMiners, validateReadOnlyMinerFields, and the miner-rename change count read these nodes instead of each rebuilding minerMap(snap.miners) from raw rows. First group of the validator-to-graph migration. Behavior unchanged, verified by the existing suite plus a new resolved-miner test. Placement counting and the remaining topology validators migrate in later groups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…#767) validateSlotCollisions now reads resolved miner nodes instead of raw MINER rows, sharing the same rack-slot fields the rest of the miner validation already reads. Behavior unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…logy view (#767) resolvePlan now builds a topologyView: the desired end-state topology (CSV rows merged over the omission-target snapshot) that placement-target validators resolve references against, built once rather than re-derived per validator. Rack and miner nodes carry the raw site/building references and a building_id flag so validateBuildingSiteTargets, validateRackPlacementTargets, and validatePlacementConsistency read the graph instead of rebuilding desired* maps from raw rows. Reference resolution is now correctly omission-aware: under remove-omitted the target view drops omitted topology, matching the prior targetSnap-based calls. Action classification and prevName still read the full snapshot. Behavior verified by the existing suite plus a new mode-parity test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rough the graph (#767) validateRackGridPositions and validateRackSlotBounds now read resolved rack and miner nodes plus the shared topologyView (building layout maps and rack dimensions) instead of rebuilding desiredBuildingMap / desiredBuildingLayoutIDMap / desiredRackMap per call. Rack nodes now carry the parsed building_id reference for the grid-building lookup. Behavior unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e plan (#767) validateRackCapacity and validateExistingSlotsFitRackDimensions now take the resolved plan: incoming miners come from resolved.miners, retained/hidden miners from resolved.population, rack dimensions from topology.racksByLabel, and rack-rename mapping from the resolved racks' id->label. This drops their per-call desiredRackMap / desiredRackLabelsByID rebuilds. Behavior unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hrough the plan (#767) validateBuildingRackCapacity, validateBuildingExistingRacksFitLayout, and validateSlotConflictsWithExisting now take the resolved plan. Building capacity lookups read topology.buildingsByCapacityKey; existing-rack layout fit reads the omission-scoped target racks plus the topology building maps; slot conflicts read resolved.miners and resolved.population with id->label rack rename mapping. The resolved plan now carries the omission-scoped target snapshot for existing-topology reconciliation. validateRackGridCollisions stays row-based: it reconciles against the full (non-omission-scoped) snapshot, which the target-scoped topology view does not model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…the plan (#767) Miner placement changes are now resolved once: classifyMinerMoves sets each existing miner's moved flag from its desired site/building/rack/slot/placement-id versus live state. The preview MOVE count reads that flag (countMinerMoveNodes) and validateMinerRenamePermission reads the renamed flag, both replacing row-based recomputation. buildPlan exposes the resolved plan on importPlan so ImportSiteMapCsv can run the rename-permission check against it. Miner nodes now carry the raw *_id cells needed for placement-identity comparison. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…behavior Removes the leave-omitted-in-place option so the importer has a single end-state semantic: omitted topology is removed on commit. Any non- REMOVE_OMITTED mode carrying omissions now requires explicit confirmation instead of silently keeping omitted rows, so leave-in-place is no longer reachable. - server: the omission confirmation gate (both the request handler and buildPlan) keys on != REMOVE_OMITTED rather than == UNSPECIFIED. - client: drop the "leave omitted rows in place" radio; the omission step is now a single confirm-removal choice. - tests: the leave-in-place mode is replaced by UNSPECIFIED (identical full-snapshot topology semantics for the validator fixtures). The OMISSION_MODE_LEAVE_IN_PLACE proto enum value is now dead; deleting it from the schema + regenerating is a follow-up (needs the pinned proto toolchain). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nces (#767) Export now always emits the most-specific parent id (building_id, else site_id; rack_id for miners, else building_id/site_id) and leaves the parent-reference name columns blank. Existing entities are identified by stable id rather than by name, which removes the ambiguity-driven id-vs-name export heuristics (ambiguousBuildingLabels and the rackExport*/minerExport* helpers). Change detection stays consistent because normalizeIDReferences back-fills the parent-name columns from ids before rows are compared, so the canonical comparable rows keep names populated. Consolidated the redundant rackRows/rackComparableRow helpers and dropped now-dead code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ell (#767) Replace the (*_id, name) column pair for every parent relationship with one reference cell: blank = unassigned, a bare integer = an existing entity by id, "NAME:x" = a same-import create named x, anything else = a validation error. resolveReferences (replacing normalizeIDReferences + normalizeInferredPlacement) canonicalizes each cell in place to a canonical name with implied ancestors filled (a building reference also fills its site), so resolve/validate/apply read names exclusively and never re-interpret an id. This deletes the id/name-mismatch errors, the building name-ambiguity machinery, the desired*ByID family, and the *_id cell reads throughout; export writes the parent id into the single cell and change-detection compares canonical names. Interim: two buildings identical in (site, name) are no longer independently addressable by the name-keyed grid/collision/capacity/move validators; #767 step 7 restores id-precision via the resolved-plan graph and re-adds the four dropped id-disambiguation tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…767) Canonicalizing a parent reference to a name loses id precision when two buildings share a (site, name) pair — a case the DB permits via distinct NULL site_ids. resolveReferences now records the resolved existing building id in an internal companion cell next to the canonicalized name, and the resolved rack/miner nodes and grid/collision/capacity/move validators key on that id, falling back to (site, name) only for same-import creates (whose uniqueness is enforced separately). Restores the four id-disambiguation tests dropped when the single reference-cell model landed, now expressed in that model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Preview counts, the commit token, and (next) apply must agree on what each row does. Update classification was derived three times — node rename/move flags, a row-comparison count helper, and apply's rowsEqual — which could disagree (e.g. a name-only row with a blank id column). classifyTopologyActions now assigns every site/building/rack node its create/update verb by comparing the canonicalized row against the live comparable row across every editable column (the id column is identity, not editable, so it is excluded). computeChanges reads those node actions; the row-based count*Updates / countExistingRowUpdates* / rowKey helpers are deleted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewire the apply path to iterate the canonical resolved nodes instead of re-deriving desired topology from raw CSV rows. applySites/applyBuildings/ applyRacks/applyMiners now switch on each node's classified action (actionCreate/actionUpdate) and read the node's resolved fields, so preview, commit-token, and apply share one derivation of "what changed". Appliers prefer the resolved existing building id (node.buildingID) when resolving placement parents, disambiguating two buildings that share a (site, name) pair; they fall back to the canonical name lookup against the running apply maps for same-import creates. Miner apply keys create/update off node.renamed/node.moved rather than re-comparing row cells. Single RunInTx atomicity and the existing store-orchestration/locking bodies are preserved verbatim. Apply unit tests construct resolved nodes directly; the move-before-delete integration test drives the real resolveReferences + resolvePlan pipeline. The old row-based helpers (existingRackByIDRow, rowsEqual, desiredRackGridPosition, and the row twins of desiredRackZone) are now orphaned and removed in the task-6 dead-code sweep. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#767) The remove-omitted delete set is computed against the caller-visible snapshot (snap.miners), and RBAC-hidden rack members are tracked in a separate hiddenRackMembers list — so a hidden miner is structurally excluded from omission deletion. Add a regression test asserting that invariant end-to-end through applyOmittedRows: a hidden miner in an omitted rack is never passed to any unassign store call. The mock controller fails on any call naming the hidden device, so the guarantee is enforced by the absence of expectations for it. This closes the #767 hidden-resource delete-guard task: investigation confirmed the guard already holds (visible-only delete sets; validators already fold hiddenRackMembers into slot/capacity/conflict checks; the site delete path re-validates hidden curtailment/infrastructure resources under lock). No behavior change was needed; this test makes the delete-set invariant explicit and regression-proof. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ead row helpers (#767) Commit token: hash the resolved plan's create/update node set (identity + every mutable facet apply writes) alongside the omission counts, live snapshot fingerprint, and mode — replacing the previous blob of raw CSV rows plus the human-facing preview summary. Because apply executes the same resolved plan, a token can no longer validate a topology that apply would resolve differently. Token values change (acceptable per the plan doc); the dry-run→commit staleness contract is unchanged. Add a regression test proving the token moves when a CSV edit changes the resolved plan even against an identical snapshot, complementing the existing snapshot-drift test. Dead-code sweep: remove the row-based apply helpers orphaned once apply moved onto resolved nodes — existingRackByIDRow, rowsEqual, parseInt32Field, desiredRackGridPosition, and the row twin of desiredRackZone (the node twin desiredRackZoneForNode remains). Repoint the zone-clearing test at the node twin. Note: the dead OMISSION_MODE_LEAVE_IN_PLACE proto enum value still needs deleting + regen, which is deferred (proto regen is unreliable in a worktree). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🔐 Codex Security Review
Review SummaryOverall Risk: MEDIUM Findings[MEDIUM] Contradictory parent references are silently rewritten
[MEDIUM]
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 77b9f05038
ℹ️ 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".
There was a problem hiding this comment.
Pull request overview
Refactors the sitemap CSV import flow to resolve the uploaded CSV + live snapshot into a single canonical “resolved plan” representation that downstream stages (preview, validation, commit token, apply) can consume consistently. This also extracts the building rack-capacity invariant into the buildings domain so both the buildings service and sitemap import share the same logic.
Changes:
- Introduces
resolvedPlan/ resolved node types plus plan-driven validation and change counting. - Extracts rack-capacity helpers (
GridCapacity,RackCapacityExceeded) intoserver/internal/domain/buildings/modelsand updates callers. - Updates ProtoFleet’s import modal UX to remove the “leave omitted rows in place” option and treat omissions as a removal confirmation step; adds/updates characterization tests accordingly.
Reviewed changes
Copilot reviewed 7 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| server/internal/domain/sitemap/service_test.go | Updates sitemap import/export tests for the new single-cell reference model, plan-based tokening, and plan-driven apply helpers. |
| server/internal/domain/sitemap/resolved.go | Adds the canonical resolved-plan types and a large portion of plan-driven validation and change classification logic. |
| server/internal/domain/sitemap/resolved_test.go | Adds unit tests for resolved-plan identity classification, topology linking expectations, and omission-awareness behavior. |
| server/internal/domain/buildings/service.go | Switches capacity math to shared helpers and uses RackCapacityExceeded for the assignment guard. |
| server/internal/domain/buildings/models/models.go | Adds shared capacity helpers GridCapacity and RackCapacityExceeded. |
| server/internal/domain/buildings/models/models_test.go | Adds unit tests for the new capacity helpers. |
| docs/plans/2026-07-22-767-sitemap-canonical-plan-tdd.md | Adds a TDD documenting the canonical-plan refactor approach and constraints. |
| client/src/protoFleet/features/fleetManagement/components/SiteMapCsvImportModal.tsx | Removes the “leave omitted rows” option and updates the omission prompt copy to a removal confirmation flow. |
…s, sharpen errors, align guide (#767) Resolves the automated review feedback on #808: - Remove the never-read parent-pointer fields (resolvedBuilding.site, resolvedRack.building/site, resolvedMiner.rack/building/site) and the linkSite / indexSitesByIdentity / existingSitesByName machinery that only fed them. Nodes carry their parents as canonical name refs (siteRef/buildingRef) plus the resolved parent id; the file header now says so instead of claiming pointer links. (Copilot: misleading header.) - Delete the unreachable site/building mismatch branches in validatePlacementConsistency. resolveReferences canonicalizes a miner's site/building to its rack's parents before the node is built (rack wins over building wins over site), so those branches could never fire; the precedence is now documented in place. (Codex: silent parent overwrite — precedence is intended, so the dead checks are removed rather than the behavior changed.) - Include the offending device_identifier in the unknown-miner validation error. (Copilot.) - Update the export guide (and its test) to describe remove-omitted as the only omission behavior; drop the stale leave-in-place sentence. (Copilot.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
🤖 Triaged both Codex Security Review findings: Finding 1 — leave-in-place omission mode (MEDIUM): Working as intended. Remove-omitted is now the only committable omission mode (locked product decision, commit f3c2a7c). The commit path rejects any other mode, and the preview surfaces the omitted counts and requires explicit confirmation before removal. The dead Finding 2 — silent parent-reference overwrite (MEDIUM): Fixed in 18421d4. This is intended precedence, not a silent bug: for a single row, a rack reference wins over a building reference, which wins over a site reference. The finding pointed at dead id-vs-name mismatch validation branches that could never fire under the single-reference-cell model. Those dead branches are now deleted and the rack-wins precedence is documented inline in Independent security review: LOW risk, no new vulnerabilities. Import stays inside a single |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 18421d4f06
ℹ️ 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".
…#767) Under REMOVE_OMITTED, validateRemoveOmittedReferences checked only that some (site, name) BUILDING row remained. Two unassigned buildings can share a (site, name) pair via distinct NULL site_ids, so a rack or miner referencing the omitted twin by numeric id passed validation, was placed under that id by applyRacks/applyMiners, and was then orphaned when applyOmittedRows deleted it in the same transaction — regressing the id-qualified disambiguation path. Check the resolved id preserved in refBuildingIDCell against the retained BUILDING rows' own ids instead of the canonicalized name. Id references only ever resolve to existing buildings (NAME: creates leave the cell nil), so bare-id refs get the id check and name/create refs keep the name check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#767) The canonical-plan refactor introduced switches on the new nodeAction and refKind enums that relied on fall-through for the unhandled cases. The exhaustive linter (default config, so a default case does not count) flagged them. List the missing cases explicitly with no-op bodies — behavior is unchanged: - applySites: actionNone/actionDelete are no-ops (deletes run in applyOmittedRows). - resolveSite/resolveBuilding/resolveRack: refUnassigned (blank cell) resolves to empty. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "creates an automation" test waited for the modal to close, then synchronously looked up the new row by text. The row list re-renders a tick after the modal closes, so under CI load the lookup ran before the row existed and failed with "Unable to find an element with the text: High LMP spike" (empty-state DOM). Add the same `await screen.findByText(...)` guard the sibling edit/delete test already uses for this documented race. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ToBuilding (#767) capacity is already bound and guarded >0 by the enclosing if; reuse it directly instead of calling RackCapacityExceeded, which recomputed GridCapacity and re-checked >0. Behavior-identical; the shared helper stays for the sitemap path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| sitemodels "github.com/block/proto-fleet/server/internal/domain/sites/models" | ||
| ) | ||
|
|
||
| // resolved.go builds the canonical resolved plan for a sitemap import: parsed |
There was a problem hiding this comment.
nit: something like import_plan would have been clearer
| sites: desiredSiteSet(siteRows, target.sites), | ||
| buildingKeys: rowSetFromDesiredBuildings(buildingRows, target.buildings), | ||
| buildingsByKey: desiredBuildingMap(buildingRows, target.buildings), | ||
| buildingsByLayoutID: desiredBuildingLayoutIDMap(buildingRows, target.buildings), | ||
| buildingsByCapacityKey: desiredBuildingCapacityMap(buildingRows, target.buildings), | ||
| racksByLabel: desiredRackMap(rackRows, target.racks, buildingRows, target.buildings), |
There was a problem hiding this comment.
I think we can simplify and DRY this up a bit, the helpers seem to be doing similar operations. Maybe call desiredBuildingList once, then iterate over the desiredBuildings and create the different maps (buildingsByKey, buildingsByLayoutID, buildingsByCapacityKey).
Can also pass buildingsByKey and buildingsByID to desiredRackMap so it can use them to resolve rack parents.
This way there will be fewer places to edit if we need to change the csv in the future
Reviewable diff: +2135/-1594 across 6 files (excludes generated, test, and story files).
Summary
Site-map CSV import lets an operator upload a spreadsheet of sites, buildings, racks, and miners and have Proto Fleet reconcile the fleet's topology to match it. Previously each stage of that flow — the dry-run preview, the validation errors, the "did anything change since you previewed" token, and the final database writes — worked out from scratch what the CSV meant. Those four independent re-derivations drifted apart, which is where most of the reported edge-case bugs came from.
This PR makes the import resolve the CSV into one canonical plan first, and then has preview, validation, the commit token, and apply all read from that single plan. Same feature, same CSV format from the user's point of view; the internals now have a single source of truth instead of four.
How it works
The import runs in one direction: raw CSV → resolved plan → everything else.
Parse. The uploaded bytes are split into the four sections (SITE, BUILDING, RACK, MINER), each a list of rows.
Load a snapshot. The server reads what currently exists in the database: all sites, buildings, and racks for the org, plus the miners the uploader is allowed to see. Miners the uploader can't see but which still occupy a rack slot are kept in a separate "hidden members" list so they're never silently trampled or deleted.
Resolve references. Every parent link in the CSV is a single cell, read like this:
building=12) → the existing building with id 12NAME:x(e.g.building=NAME:Warehouse B) → a building being created in this same import whose name is "Warehouse B"This replaces the old scheme where each link was two columns (a name and an id) that could disagree. Resolving also fills in implied ancestors (a rack that names a building inherits that building's site) and records the matched building's real id internally, so two different buildings that happen to share the same (site, name) stay distinct.
Build the plan. Each row becomes a resolved node (site / building / rack / miner) tagged with an action — none, create, or update — decided by comparing the canonical CSV row against the live row field by field. Alongside the nodes, a "topology view" describes the desired end state (CSV merged over the current snapshot) that the validators check against.
Validate, count, tokenize — all off the plan. Grid-position collisions, rack and building capacity, slot conflicts and bounds, and rename permissions all read the resolved nodes and the shared topology view (and fold in those hidden members). The preview change-counts come from the node actions. The commit token is a hash of the resolved plan plus the live snapshot fingerprint.
Apply. On commit, the token is re-checked (if the CSV or the live database changed since the dry-run, the token won't match and the commit is refused). Then a single database transaction walks the resolved nodes and performs each create/update, and — because "remove omitted rows" is now the only removal mode — deletes anything the CSV left out.
Example of the token guard: you dry-run and get a token. A teammate moves a rack in the UI before you hit commit. Your token no longer matches the fresh snapshot, so the commit is rejected and you're asked to dry-run again, rather than applying a stale plan.
flowchart TD CSV["Uploaded CSV"] --> Parse["Parse into SITE/BUILDING/RACK/MINER rows"] DB["Live database"] --> Snap["Load snapshot (org topology + visible miners + hidden members)"] Parse --> Resolve["Resolve references (single-cell: blank / id / NAME:x)"] Snap --> Resolve Resolve --> Plan["Resolved plan: nodes tagged none/create/update + topology view"] Plan --> Preview["Preview change counts"] Plan --> Validate["Validation (grid, capacity, slots, renames)"] Plan --> Token["Commit token = hash(plan + snapshot)"] Plan --> Apply["Apply: one transaction, walk nodes, delete omitted"] Apply --> DBsequenceDiagram actor User participant Import as Import service participant DB User->>Import: Upload CSV (dry_run) Import->>DB: Load snapshot Import->>Import: Resolve plan, validate, count Import-->>User: Preview + commit token User->>Import: Commit (with token) Import->>DB: Reload snapshot Import->>Import: Recompute token alt token matches Import->>DB: Apply plan in one transaction Import-->>User: Success + changes else snapshot drifted Import-->>User: Rejected — dry-run again endAreas of the code involved
server/internal/domain/sitemap/resolved.goresolveReferences,resolvePlan, the topology view, action classification, and all validators that now read the plan.server/internal/domain/sitemap/service.goserver/internal/domain/buildings/models/models.goGridCapacityandRackCapacityExceeded— the building rack-capacity rule extracted so both the buildings domain and sitemap import share one implementation.server/internal/domain/buildings/service.goclient/.../SiteMapCsvImportModal.tsxdocs/plans/2026-07-22-767-sitemap-canonical-plan-tdd.mdKey technical decisions & trade-offs
NAME:) instead of a name+id column pair. The old pair could contradict itself and needed reconciliation logic; one cell removes that whole class of ambiguity. Trade-off: existing entities must be referenced by id, and same-import creations byNAME:— bare names are a validation error.OMISSION_MODE_LEAVE_IN_PLACEproto enum value is left for a follow-up because proto regen is unreliable in this worktree.aisles, racksPerAisle, resultingCount) so a future interactive-reparent fix (Rack reparent: over-capacity false rejection when a Manage-racks confirm both removes and reparents into a full building #778) can reuse it.Testing & validation
go build ./...,go vet, andgofmt -lclean; the fullinternal/domain/sitemappackage test suite passes, as do the newbuildings/modelscapacity tests.OMISSION_MODE_LEAVE_IN_PLACEproto enum value (deferred — needs proto regen outside a worktree); no new end-to-end/integration test beyond the existing service-level suite.