feat(server): infrastructure device integration foundation — facility fans first (1/5)#724
Conversation
…pter registry infrastructure_device table stores protocol-blind device facts plus an opaque adapter-owned driver_config jsonb blob. InfrastructureService proto carries driver_type as a string and driver_config as JSON text so new driver types need no proto change. The driver registry maps driver_type to adapter factories; the modbus_tcp adapter validates config (unit ID, port, register address, write mode) and restricts endpoints to private/loopback/link-local addresses since Modbus has no authentication. Write-path I/O lands with reconciler sequencing (PR 4/5 of the facility fan plan, #723).
Domain service validates protocol-blind fields (name, device kind, fan count normalization, site org membership) and delegates driver_config validation to the driver registry, so core validation never learns protocol details. Connect handlers gate reads on site:read and writes on site:manage, mirroring buildings CRUD. Integration tests run CRUD, validation, and org isolation against real Postgres. Deletion currently soft-deletes without a reference guard; the FailedPrecondition guard lands with response-profile fan settings when facility_fan_device_ids exists (#723, PR 3).
Create/Update now run in a transaction with LockSiteForWrite replacing
the SiteBelongsToOrg pre-check (same race fix buildings received), and
DeleteSite's cascade soft-deletes the site's infrastructure devices so
controllable fans cannot outlive their site. Store maps empty
driver_config to {} so the jsonb column never sees invalid bytes.
Adds handler tests (auth gates, translation round-trip, empty-config
rejection), endpoint-guard regression pins for adversarial vectors
(IPv4-mapped IPv6, unspecified/broadcast/multicast, CGNAT, IMDS), and
integration coverage for site-filter discrimination, update rename
collision, soft-deleted name reuse, and the site cascade.
🔐 Codex Security Review
Review SummaryOverall Risk: NONE FindingsNo findings. NotesReviewed Generated by Codex Security Review | |
There was a problem hiding this comment.
Pull request overview
This PR introduces the server-side “infrastructure device” foundation (facility fans / fan groups) as a persisted domain concept, with a protocol-agnostic driver seam (registry + adapter validation) and a permission-gated Connect CRUD API. It’s PR 1/5 in the facility fan curtailment series and is intended to be operationally inert (no Modbus write path yet), while establishing the DB/proto/service surface area for subsequent PRs.
Changes:
- Adds
infrastructure_devicepersistence (migration + sqlc queries + store + domain models) and wires it intofleetd. - Implements a driver adapter registry plus a
modbus_tcpadapter focused on config parsing/validation (write path stubbed). - Adds Connect RPC CRUD handlers and RBAC procedure permission mapping; extends
DeleteSitecascade to soft-delete infrastructure devices.
Reviewed changes
Copilot reviewed 27 out of 36 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| server/sqlc/queries/site.sql | Adds sqlc query to soft-delete infrastructure devices during site delete cascade |
| server/sqlc/queries/infrastructure_device.sql | New sqlc CRUD queries for infrastructure_device |
| server/migrations/000120_create_infrastructure_device_table.up.sql | Creates infrastructure_device table, constraints, indexes, updated_at trigger |
| server/migrations/000120_create_infrastructure_device_table.down.sql | Drops trigger/indexes/table for rollback |
| server/internal/handlers/sites/handler_test.go | Updates DeleteSite cascade expectations to include infra-device soft delete |
| server/internal/handlers/middleware/rpc_permissions.go | Registers InfrastructureService RPC permissions (site:read/site:manage) |
| server/internal/handlers/middleware/rpc_permissions_test.go | Adds InfrastructureService to RBAC contract coverage |
| server/internal/handlers/infrastructure/translate.go | Translates proto ↔ domain models for InfrastructureService |
| server/internal/handlers/infrastructure/handler.go | New Connect handler implementing InfrastructureService CRUD |
| server/internal/handlers/infrastructure/handler_test.go | Auth gating + translation tests for InfrastructureService handler |
| server/internal/domain/stores/sqlstores/site.go | Implements SoftDeleteInfrastructureDevicesBySite in SQLSiteStore |
| server/internal/domain/stores/sqlstores/infrastructure_device.go | New SQL store implementation for infrastructure devices |
| server/internal/domain/stores/interfaces/site.go | Extends SiteStore interface with infra-device cascade method |
| server/internal/domain/stores/interfaces/mocks/mock_site_store.go | Generated mock updated for new SiteStore method |
| server/internal/domain/stores/interfaces/mocks/mock_infrastructure_device_store.go | Generated mock for InfrastructureDeviceStore (generated) |
| server/internal/domain/stores/interfaces/infrastructure_device.go | New InfrastructureDeviceStore interface (+ mockgen directive) |
| server/internal/domain/sites/service.go | Extends DeleteSite cascade to soft-delete infrastructure devices |
| server/internal/domain/sites/service_test.go | Updates DeleteSite transaction cascade test expectations |
| server/internal/domain/sites/models/models.go | Adds DeletedInfrastructureDeviceCount to DeleteSiteResult |
| server/internal/domain/infrastructure/service.go | New domain service for infrastructure-device CRUD + normalization/validation |
| server/internal/domain/infrastructure/service_integration_test.go | Postgres integration tests for infra-device CRUD/validation/cascade |
| server/internal/domain/infrastructure/models/models.go | Domain model + params/filter types for infrastructure devices |
| server/internal/domain/infrastructure/driver/modbustcp/modbustcp.go | Modbus TCP adapter config schema + validation; SetState stub |
| server/internal/domain/infrastructure/driver/modbustcp/modbustcp_test.go | Unit tests for Modbus config validation + SetState stub behavior |
| server/internal/domain/infrastructure/driver/driver.go | Driver Controller interface + registry implementation |
| server/internal/domain/infrastructure/driver/driver_test.go | Unit tests for registry resolution/validation delegation |
| server/cmd/fleetd/main.go | Wires infrastructure store/service and mounts InfrastructureService handler |
| proto/infrastructure/v1/infrastructure.proto | Defines InfrastructureService + messages (opaque driver_config string) |
| docs/plans/2026-07-09-facility-fan-curtailment-integration-plan.md | Plan doc for the 5-PR delivery stack |
| server/generated/sqlc/site.sql.go | Generated sqlc output (generated — skip) |
| server/generated/sqlc/models.go | Generated sqlc model for InfrastructureDevice (generated — skip) |
| server/generated/sqlc/infrastructure_device.sql.go | Generated sqlc bindings (generated — skip) |
| server/generated/sqlc/db.go | Generated sqlc prepared statements wiring (generated — skip) |
| server/generated/grpc/infrastructure/v1/infrastructurev1connect/infrastructure.connect.go | Generated Connect stubs (generated — skip) |
| server/generated/grpc/infrastructure/v1/infrastructure.pb.go | Generated protobuf Go types (generated — skip) |
| client/src/protoFleet/api/generated/infrastructure/v1/infrastructure_pb.ts | Generated TS protobuf types (generated — skip) |
Files not reviewed (2)
- server/internal/domain/stores/interfaces/mocks/mock_infrastructure_device_store.go: Generated file
- server/internal/domain/stores/interfaces/mocks/mock_site_store.go: Generated file
Addresses the Codex security findings and Copilot review comments on #724: - Infrastructure RPCs now authorize against the device's site (ResourceContext{SiteID}) instead of the bare org context: Create checks the request's site, Get/Update/Delete resolve the device then authorize its current site (Update also checks the target site on a move), and List filters results to sites the caller can read — so an org-wide grant narrowed away for a site no longer reaches that site's device config. - All five InfrastructureService procedures added to SensitiveBodyProcedures so driver_config (OT endpoint map) never lands in debug request/response logs, with a config contract test. - validateAndNormalize trims and requires driver_type, returning a direct InvalidArgument instead of the registry's unknown-type error. - DeleteSite's audit description and metadata now include the cascade-deleted infrastructure device count.
|
Both Codex security findings are addressed in c799323: [MEDIUM] Site-scoped RBAC bypass — all five RPCs now authorize against the device's site via
[MEDIUM] driver_config in debug logs — all five The note about the endpoint guard admitting loopback/link-local (incl. IMDS) targets remains deliberate for v1 — it's pinned by an explicit test and flagged in the PR description to revisit when the write path lands (PR 4). |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c799323a2a
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…losed endpoint guard Second round of Codex findings on #724. - Update/Delete now predicate the SQL write on the site the caller was authorized against (expected_site_id in the WHERE clause). A concurrent site move between the authorization read and the write yields 0 rows -> NotFound, instead of mutating/deleting a device now in a site the caller may not manage. Threaded ExpectedSiteID through UpdateParams, the store, and the delete signature; handler passes the resolved current site. Integration test proves both paths fail closed after a simulated move. - modbus_tcp endpoint validation now fails closed: only private (RFC1918 / IPv6 ULA) addresses are accepted. Loopback, link-local (incl. 169.254.169.254 IMDS), multicast, broadcast, unspecified, and public addresses are all rejected, removing the SSRF/local-pivot surface before the write path lands. Tests and plan doc updated.
|
Both new Codex security findings are addressed in 523891b: [MEDIUM] Mutations authorize a stale site — Update and Delete now predicate the SQL write on the site the caller was authorized against. The handler resolves the device, authorizes its current site (and, on a move, the target site), then passes that current site into the mutation as [MEDIUM] Endpoint validation permits server-local/metadata targets — the guard now fails closed: only private RFC1918 / IPv6 ULA addresses are accepted. Loopback, link-local (incl. 169.254.169.254 IMDS), multicast, broadcast, unspecified, and public addresses are all rejected, removing the SSRF/local-pivot surface before the write path is wired. Tests updated to assert the rejections; the plan doc records that any non-RFC1918 control endpoint would be a future explicit per-site allowlist decision rather than a blanket allowance. All touched packages pass (including the Postgres integration suite) and |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 523891bc9b
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…true - validateAndNormalize trimmed driver_type for validation but the untrimmed value was persisted, so a padded key like ' modbus_tcp ' would pass create/update and then fail registry lookups at command time. The normalized value is now copied back before the write; integration test pins the trimmed persistence. - Create's enabled field is now optional with presence tracking: omitted defaults to true (matching the column DEFAULT), explicit false is preserved — API-created devices no longer silently default disabled via proto3's zero value. Handler test covers both.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b6e9850da5
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…al-time allowlist precondition - driver_config (OT control topology: endpoint, unit ID, register address) is now returned only to callers holding site:manage for the device's site. site:read callers get the display fields with an empty blob, per device in List and on Get; Create/Update echo the config their site:manage gate already covers. Tests pin both the redacted and included paths. - The save-time RFC1918/ULA endpoint guard cannot know a site's commissioned OT subnet, so the write path's dial-time authorization is now recorded as an explicit security precondition on modbustcp.SetState and in the plan doc: a per-site commissioned control-subnet allowlist (captured at Gap 1 commissioning) plus server-infrastructure CIDR rejection must land with the write path, and writes stay disabled for a site until commissioned.
|
Both findings from the latest security review are addressed in 79b3a72: [MEDIUM] Read APIs expose full driver configuration to site-read callers — [MEDIUM] Private-IP validation too broad for future raw Modbus writes — agreed that RFC1918 is a save-time bound, not dial-time authorization. The full fix (a per-site commissioned control-subnet allowlist plus server-infrastructure CIDR rejection) requires per-site commissioning data that doesn't exist until the write path's commissioning checklist runs, so it lands with the write path itself — and is now recorded as an explicit SECURITY PRECONDITION doc comment on |
…e-site hook DeleteSiteResponse already returns the count, but the client hook's DeleteSiteCounts dropped it, so callers couldn't tell fans were deleted.
The five procedures were in SensitiveBodyProcedures but not SessionOnlyProcedures, so a leaked API key with site permissions could read or rewrite the OT control topology. No API-key automation consumes this service; the in-process reconciler bypasses transport interceptors.
…urrent value Create already treated enabled as optional-with-default, but Update used a plain bool: a client omitting the field silently disabled the device via the proto3 zero value. Omitted now preserves the device's existing value (in both directions); an explicit value still wins. Handler call-site and tests land with the list-filter commit that follows.
…lter List fetched every org device and dropped unreadable rows per-device in the handler. EffectivePermissions.SiteScopeFor now projects the caller's site:read authority as either an allowlist (site-scoped grants) or a denylist (org grant narrowed away at specific sites); middleware.SiteScopeForPermission wraps it with the standard actor/fail-closed handling, and the handler composes it into the query via SiteIDs/ExcludedSiteIDs. An empty readable set short-circuits to an empty response without touching the DB. The per-device canReadSite check stays as a zero-cost fail-closed backstop. Also wires the preserved-enabled Update call-site and its tests from the previous commit.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a09254e800
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Filling the omitted value from the pre-authorization read left a read-then-write race: a concurrent toggle between the read and the commit would be overwritten with the stale value. UpdateParams.Enabled is now *bool carried into the query as a nullable param, and the UPDATE preserves the row's current value via COALESCE when it is NULL — presence travels all the way to the write.
Main merged two notification_metric_sample migrations that took 000120 and 000121 while this branch was in review; golang-migrate requires unique versions, so the infrastructure_device migration moves to 000122. Content unchanged; integration tests ran the full merged chain.
…get site The delete cascade now soft-deletes infrastructure devices, whose own RPCs evaluate site:manage against the concrete site — but DeleteSite only checked site:manage at org scope, ignoring narrowing. A caller with an org-wide grant narrowed away from site X by a site-scoped assignment could still cascade-delete X's infrastructure devices. Re-check site:manage against the target site before the cascade runs. Addresses the Codex Security Review MEDIUM finding on PR #724.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 64975ce31c
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Get/Update/Delete resolved the device under org scope and then returned PermissionDenied when the caller lacked site:read on its site, letting site-scoped callers enumerate device IDs outside their sites by comparing PermissionDenied vs NotFound. Route all three verbs through a shared resolve helper that masks the no-read case as NotFound; PermissionDenied is now reserved for callers who can read the device but lack site:manage. Addresses the Codex Security Review LOW finding on PR #724.
The service was mounted but omitted from reflectEnabledServices, so it was invisible to SDKs and operator tooling that discover fleetd APIs via the reflection endpoint. The stated policy on the list is "every authenticated service is reflected" — add it alongside the other site/building/curtailment services.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6e7687e708
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
unit_id, register_address, port, and write_mode validation errors included the caller-provided value. The request logger records err on failures even for SensitiveBodyProcedures, and these fields are OT control topology — a near-miss submission next to a real control value could land it in server logs. Return field-only range messages, the same policy the endpoint check already documents, and pin no-echo in the rejection test.
Raw json.Unmarshal errors can embed the submitted literal (numeric overflow errors include the number; syntax errors echo the offending character), so wrapping them verbatim could still leak OT topology values into API error details and server logs despite the no-echo policy on validation messages. Map decode failures to field-only messages (field name + expected type for type errors, generic otherwise) and pin no-echo for non-integer and overflowing values.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b5eea94c38
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Decoding into a plain int turned an omitted, misspelled, or null register_address into 0 — a valid raw address (the RUN/STOP coil) — so the config validated and the future write path would command register 0 instead of the intended control word. Track presence with a pointer: missing/null is rejected as "register_address is required" while explicit 0 remains accepted.
Config.Endpoint claimed loopback and link-local addresses are accepted; validateEndpoint deliberately rejects both (loopback targets server-local services, link-local includes cloud instance-metadata) — point at validateEndpoint instead of restating the policy wrong. The Registry doc said the reconciler "uses" it to command devices, but the protocol I/O phase hasn't landed; rephrase as future tense.
Reviewable diff: +2452/-59 across 31 files (excludes generated, test, and story files; 513 of the additions are the committed plan doc).
Summary
This is the foundational PR for integrating external infrastructure devices into Proto Fleet: equipment on the facility side (fans, and later PDUs, dampers, pumps, ...) that the server can inventory and eventually command, without the core ever learning device protocols. It delivers the persisted
infrastructure_deviceconcept, a protocol-agnostic driver adapter registry, and a permission-gated Connect CRUD API. Facility fans (a single fan or fan group behind a PLC/drive) are the first device kind, and Modbus TCP is the first driver adapter — motivated by curtailment sequencing, but the foundation is deliberately kind- and protocol-agnostic.Stack: PR 1 of 5 per the committed plan (docs/plans/2026-07-09-facility-fan-curtailment-integration-plan.md, tracked by #723). The fan use case continues in: PR 2 client add/edit UI, PR 3 response-profile fan settings, PR 4 Modbus write path + simulator, PR 5 reconciler sequencing (fans off after miners confirm curtailed; fans on before miners restore). No device behavior activates from this PR — the write path (
SetState) intentionally returns not-implemented, so this is inert surface area. Diff is againstmain.How it works
An operator with
site:manageon the target site callsInfrastructureService.CreateInfrastructureDevicewith protocol-blind fields (site, building, name, device kind, fan count) plus adriver_typekey and an opaquedriver_configJSON blob. The domain service validates the blind fields itself, then resolves the adapter fordriver_typefrom the driver registry and delegates blob validation to it — the core never parses protocol config. The Modbus TCP adapter enforces unit ID 1-247, port bounds, register address 0-65535, write mode coil/holding_register, and fails closed on endpoints: only private RFC1918 / IPv6 ULA IP literals are accepted — loopback, link-local (incl. IMDS), multicast, broadcast, unspecified, and public addresses are all rejected, since the server will eventually open raw, unauthenticated Modbus writes to the stored address. Validation errors never echo the submitted endpoint, so a near-miss OT address cannot leak into request logs.All five RPCs authorize against the device's site (
ResourceContext{SiteID}), not the bare org: Create checks the request's site; Get/Update/Delete resolve the device under org scope and then authorize its current site (Update also checks the target site on a move); List pushes the caller's readable-site set into the SQL filter —EffectivePermissions.SiteScopeForprojectssite:readauthority as an allowlist (site-scoped grants) or a denylist (org grant narrowed away at specific sites), so unreadable rows are never fetched and an empty readable set short-circuits without touching the DB. Update/Delete additionally predicate the SQL write on the site the caller was authorized against (expected_site_idin the WHERE clause), so a concurrent site move between authorization and write fails closed as NotFound.driver_config— the OT control topology — is returned only tosite:manageholders (per device in List and on Get); all five procedures are inSensitiveBodyProcedures(bodies stay out of debug logs) andSessionOnlyProcedures(a leaked API key cannot reach the OT control surface). Every mutation emits aninfrastructure_device.created/.updated/.deletedactivity event after commit, carrying protocol-blind metadata only (neverdriver_config).Writes run in a transaction that row-locks the parent site (
LockSiteForWrite), closing the check-then-write race against concurrentDeleteSite; a device move locks both the source and target sites (ascending ID order, deadlock-free), so a move out of a site serializes against that site's deletion instead of slipping a live device past the cascade.DeleteSite's cascade now also soft-deletes the site's infrastructure devices, with the count surfaced throughDeleteSiteResponse, theListSitesprojection, and the client's delete-confirmation dialog. Rows persist toinfrastructure_devicewith org/site scoping identical to buildings; reads gate onsite:read.Extensibility contract (the point of this PR):
driver_configcrosses the wire as an opaque JSON string with no per-protocol messages.models.ValidKind, plus the adapter/form work above.device_kindis a string on the wire precisely so this is never a breaking change. Sub-component control (e.g. PDU outlets) fits by modeling each outlet as its own device row —driver_configalready carries arbitrary addressing.SetStatetakes a struct-shapedDesiredState(power mode today) so fields likeSpeedPercentare additive;Capabilities()lets the UI gate per-driver features.ReadStatusis doc-reserved for v2 status read-back.Diagrams
flowchart LR Client["Connect client"] --> Handler["InfrastructureService handler: site-scoped RBAC + driver_config redaction"] Handler --> Svc["infrastructure.Service (protocol-blind validation)"] Svc --> Registry["driver.Registry: driver_type to adapter"] Registry --> Modbus["modbustcp adapter (first): ValidateConfig (bounds + RFC1918/ULA endpoint guard)"] Registry --> Future["future adapters: snmp, http, bacnet, ..."] Svc --> Tx["Tx: LockSiteForWrite + insert/update predicated on expected_site_id"] Tx --> DB[("infrastructure_device")] Svc --> Audit["activity log: created/updated/deleted (no driver_config)"]Review guide: files by layer
Grouped by layer, in suggested reading order. The four bold files carry the design and security decisions; the rest mostly mirror established buildings/sites conventions.
1. Wire contract — the extensibility seam
proto/infrastructure/v1/infrastructure.protodevice_kind/driver_typeas strings,driver_configas an opaque JSON string, the redaction contract documented on the field, optionalenabledwith presence tracking on both Create (omitted = true) and Update (omitted = preserve current)proto/sites/v1/sites.protoinfrastructure_device_countonSiteWithCountsanddeleted_infrastructure_device_countonDeleteSiteResponse2. Schema & queries
server/migrations/000122_create_infrastructure_device_table.{up,down}.sqlbuilding(000044)server/sqlc/queries/infrastructure_device.sqlsite_ids/excluded_site_idslist filters (the caller's readable-site set lands here);expected_site_idin the Update/Delete WHERE clauses (stale-site fail-closed guard); soft-deleteRETURNINGfor the audit stampserver/sqlc/queries/site.sqlListSitescount subquery + the cascade soft-delete statement3. Driver adapter layer — the protocol-agnostic boundary
server/internal/domain/infrastructure/driver/driver.goControllerinterface (SetState,ValidateConfig,Capabilities), thedriver_typeregistry, and theReadStatusreservation — the seam every future protocol plugs intoserver/internal/domain/infrastructure/driver/modbustcp/modbustcp.goSetState4. Domain service
server/internal/domain/infrastructure/service.goLockSiteForWrite(source and target site on a move, ascending-ID order viasiteLockOrder), registry delegation, post-commit audit events (metadata never includesdriver_config), the PR 3 delete-guard TODOserver/internal/domain/infrastructure/models/models.goExpectedSiteIDsemantics onUpdateParams5. Persistence
server/internal/domain/stores/interfaces/infrastructure_device.go,server/internal/domain/stores/sqlstores/infrastructure_device.godeviceFromRowmapper, empty config normalized to{}, org-scoped queries onlyserver/internal/domain/stores/interfaces/site.go,server/internal/domain/stores/sqlstores/site.goSoftDeleteInfrastructureDevicesBySite6. API surface & security gates
server/internal/handlers/infrastructure/handler.godriver_configredaction viamiddleware.HasPermission(fail-closed on wiring errors),ExpectedSiteIDthreadingserver/internal/domain/authz/effective.goSiteScopeFor(key)accessor: projects narrowing semantics into an allowlist/denylist a SQL query can consumeserver/internal/handlers/middleware/permission.goSiteScopeForPermissionhelper wrapping the accessor with the standard internal-actor short-circuit and fail-closed handlingserver/internal/handlers/infrastructure/translate.goenableddefaults to true on Create; on Update the presence pointer passes through to the store so preservation happens in the UPDATE itselfserver/internal/handlers/middleware/rpc_permissions.gosite:read, writessite:manageserver/internal/handlers/interceptors/config.goSensitiveBodyProcedures(bodies out of debug logs) andSessionOnlyProcedures(no API-key access to the OT control surface)server/cmd/fleetd/main.go7. Site delete cascade (server + client)
server/internal/domain/sites/service.go,server/internal/domain/sites/models/models.goserver/internal/handlers/sites/handler.go,server/internal/handlers/sites/translate.goclient/src/protoFleet/api/sites.tsDeleteSiteCountscarriesdeletedInfrastructureDeviceCountthrough thedeleteSitehookclient/src/protoFleet/features/sites/components/SiteDeleteDialog/SiteDeleteDialog.tsxinfrastructureDeviceCountincluded in the cascade gate and summary ("deleted", not "unassigned")8. Tests, generated code, docs
*_test.go,SiteDeleteDialog.test.tsxservice_integration_test.goandhandler_test.gopin the security behaviorsserver/internal/testutil/database_setup.goserver/generated/**,client/src/protoFleet/api/generated/**just gen— skipdocs/plans/2026-07-09-facility-fan-curtailment-integration-plan.mdKey technical decisions & trade-offs
driver_config(jsonb + JSON string on the wire) over typed per-protocol proto messages — new protocols need no proto/schema change; trade-off: server-side validation is the only schema enforcement, so it lives in the adapter.Drivercontract is mining-specific; the interface is extractable to a subprocess later (e.g. site-local Modbus RTU on fleetnode) without touching callers.device_kindas a constrained string, not a proto enum — new kinds (PDU, damper, pump) are additive validation-list edits, never wire-format changes. The fan-specific bits (fan_count, kind CHECKs) are deliberately named and contained, to be generalized when a second kind arrives rather than speculatively abstracted now.modbustcp.SetStateand in the plan doc.buildings.GetBuildingStats); Update/Delete carry the authorized site into the SQL WHERE clause so a concurrent site move invalidates the mutation. Response-widening probes usemiddleware.HasPermission, so an authz wiring failure surfaces as Internal instead of silently redacting.EffectivePermissions.SiteScopeFor+middleware.SiteScopeForPermissionproject the caller'ssite:readauthority (narrowing semantics included) intoSiteIDs/ExcludedSiteIDson the query, over fetching the whole org and dropping rows per-device. Sets the pattern for future site-scoped list endpoints; a per-device in-memory check remains as a zero-cost fail-closed backstop.enabledpreserves the current value, atomically in SQL — not "default true": an unrelated update must neither silently disable nor silently re-enable a device. Presence travels to the write as a nullable param (COALESCE(narg('enabled'), enabled)in the UPDATE), so there is no read-then-write window against a concurrent toggle. Explicit values always win.driver_config); the delete event stamps from the soft-delete's ownUPDATE … RETURNINGrow (mirrorsSoftDeleteBuilding), so the audit reflects the device actually deleted even under a concurrent rename/move.facility_fan_device_idsdoesn't exist yet; aFailedPreconditionguard mirroring the response-profile/automation-rule pattern lands with that column (TODO recorded in code).Testing & validation
driver_typepersistence, site-filter discrimination across two real sites (allowlist andExcludedSiteIDsdenylist, including exclusion overriding inclusion), update rename collision, soft-deleted name reuse, cross-org isolation for all verbs, site-cascade soft-delete, stale-site-authorization fail-closed on Update and Delete, move-out-of-a-deleted-site fail-closed (the DeleteSite race window), audit-event emission (incl.driver_configexclusion and no-event-on-failure), validation matrix (kind, fan count, unknown driver, rejected endpoints, unit-ID bounds).SiteScopeForshapes (org-wide/denylist, allowlist, zero-permission narrowing, nil receiver);SiteScopeForPermission(allowlist/denylist projection, internal-actor short-circuit, unknown-actor and missing-permissions fail-closed, unauthenticated).driver_configredaction for read-only callers (redacted and included paths),ExpectedSiteIDcarried into the write, omitted-enableddefaulting on Create and presence pass-through on Update (with SQL-level preservation pinned by an integration test: disabled state survives a rename withenabledomitted), unauthenticated path, proto-domain translation round-trip, emptydriver_configrejection.SensitiveBodyProceduresandSessionOnlyProcedures; RBAC contract test covers the new service viaregisteredServices; client tests pin the delete-dialog cascade summary (combined, fans-only, singular/plural).golangci-lintclean;buf lintclean; client typecheck/lint clean;just genoutput committed.SetStateprotocol I/O (stubbed by design, PR 4), client add/edit UI (PR 2), migration down-path execution.Known Residuals
Accepted advisory findings from code review, deferred deliberately:
modbustcp.ParseConfig— unknown JSON keys indriver_configare persisted and echoed verbatim (within the 8KB cap); clients must treat the blob as untrusted when rendering.Resolved during the review rounds (each pinned by tests): site-scoped RBAC on all five RPCs, DeleteSite re-checked against the target site so narrowing can't be bypassed via the cascade, unreadable devices masked as NotFound on Get/Update/Delete so IDs can't be enumerated across site scopes, the service exposed via gRPC reflection alongside its siblings,
driver_configredaction forsite:readcallers, sensitive-body log suppression, session-only enforcement on all five procedures, stale-site fail-closed write predicates, source-site lock on device moves (serializes against DeleteSite's cascade), fail-closed endpoint guard, validation and JSON-decode errors that never echo submitted config values (endpoint, unit_id, register_address, port, write_mode), register_address presence-tracked so omitted/null cannot silently mean register 0, activity-log audit trail, cascade count inDeleteSiteResponse/ListSites/delete dialog and through the clientdeleteSitehook, trimmeddriver_typepersistence, omittedenableddefaulting to true on Create and preserving the current value on Update, readable-site SQL filtering on List (no whole-org fetch).Post-Deploy Monitoring & Validation
ListSites. No device commands can be issued (write path not implemented).000122applying cleanly;rg 'infrastructure_device'in server error logs for unexpected query failures.InfrastructureServiceRPCs return expected codes (PermissionDeniedfor under-privileged callers,InvalidArgumentfor bad configs); DeleteSite and ListSites latency unchanged.000122is provided; the service is additive, so reverting the deploy is safe with no data-loss risk (table would be dropped by the down migration only if explicitly run).Refs #723