Plant Register — API Reference

All endpoints live under two route groups:

  • HC admin surface: /v1/orgs/{orgId}/projects/{projectId}/plant-register — session-authenticated via the BFF. Gating is RBAC-avenue based, not role-name based: routes call authz.Can with plants.view / plants.create / plants.edit / plants.delete (backend/pkg/authz/keys.generated.go). The old “role-gated to master_admin / organisational_manager / project_admin / engineer” wording on this page pre-dated RBAC v2 and no longer describes the code.
  • Supplier portal surface: /v1/supplier-portal/plant-register — session scoped by supplier_org_id, read-only.

Authoritative schemas live in openapi.yaml. All request / response bodies referenced below by #/components/schemas/<Name> are generated into packages/api-client (TS) and backend/db/gen (Go) via the standard codegen pipeline.

CRUD

MethodPathGateRequest bodyResponse schema
GET/plants.view¹query filters (see below)PlantRegisterList
POST/plants.createPlantRegisterCreatePlantRegister
GET/{plantId}plants.view¹—PlantRegister
PATCH/{plantId}plants.editPlantRegisterUpdatePlantRegister
DELETE/{plantId}plants.delete—204
POST/{plantId}/restoreplants.delete— (Idempotency-Key header)PlantRegister

¹ plants.view is an avenue gate (“may you look at plants in this project at all”); the list + get routes ALSO apply row scope on top of it, which is a separate question and a separate call. Until the plant-register batch, plants.view was declared enforced: false and these routes applied no authz.Can at all — any authenticated project member could read the whole register, and /field-operators additionally returned the supplier’s full operator roster including email addresses and base rates. CTO ruling D7 closed that. Deliberate access loss: supplier-org field users who happen to be project members, and the seeded client role. Existing orgs were granted the key by a surface-mirror seed migration over every live web_admin holder (system and custom roles), so nobody lost a page they could already open. The CSV export stays on plants.edit because it exposes cost data.

List filters

Query parameters on GET /:

  • hire_type — wet | dry
  • supplier_org_id — UUID
  • is_on_site — boolean
  • auto_generate_dockets — boolean
  • unassigned_supervisor — boolean. One-directional: true narrows to plants with no default supervisor; false (or absent) does not filter. This is the M5 dashboard tile’s drill-down, and the tile links with is_on_site=true set as well so the list shows exactly the population the tile counted.
  • include_archived — boolean. true adds archived plants, sorted after every live plant, each with archived_at set. Absent or false lists live plants only. This is the register’s Archived: Show filter. A bulk edit by filter refuses each archived plant it lists.
  • search — substring match on name, plant_id, manufacturer, model
  • cursor, limit — offset pagination (limit is server-capped)

Create

PlantRegisterCreate includes:

  • Identity: name, plant_id, manufacturer, model, year_of_make, hire_type.
  • Binding: supplier_org_id, link_project_id, contract_id, and rate_line_id.
  • Toggles: is_on_site (default false).
  • Defaults: default_operator_user_id, default_supervisor_user_id, default_shift_type.
  • Scheduling: auto_docket_recurrence, auto_docket_cost_code, auto_docket_cost_code_description.
  • Optional: metadata.

rate_line_id is the plant’s sole rate source. It points at exactly one contract_rate_lines row and must belong to contract_id — the server ownership-guards it, so a foreign contract’s rate line is rejected. Whether the plant records an operator pay dimension is derived from that rate line’s basis, not from hire_type. equipment_rate_id and classification_id are legacy pass-through columns retained only so historical callers don’t 400; they are no longer read, and classification_id is derived server-side from the picked operator’s project assignment.

year_of_make is optional and range-checked to 1900–2100 in three places that must stay in lockstep: the DB CHECK chk_pr_year_of_make_range, the Go service gate, and the BFF Zod schema (which additionally refuses a year more than one year in the future). An out-of-range value returns 400, not 500.

Asset number (plant_id) must be free in the project (owner ruling 2026-09-26: “Plant: Yes to all 3.”). A create whose plant_id a live plant in the same project already uses answers 409 with error: "asset-number-in-use" and one field error on plant_id. The rule is restore’s: trimmed, case-blind, a value with no letter or digit never clashes, archived plants never block, blank is always allowed. The message names the other plant (“Excavator 20t (PR-0040) already uses asset number EX100.”); a caller held to their own plant rows gets “Another plant already uses asset number EX100.”. Create, update and restore take the same per-project advisory lock before the check, so concurrent writers of one number cannot both win (pinned against a Neon clone by TestIntegration_ArchiveFollowups_RacingWritersTakeTheNumberOnce).

Idempotency-Key is not implemented on create today — the omission is recorded in openapi.yaml and tracked as follow-up work.

Update

PlantRegisterUpdate accepts a strict subset. Immutable fields — org_id, project_id, supplier_org_id, contract_id, hire_type — are rejected with 400 if supplied. The patch uses COALESCE semantics: an omitted field keeps its stored value. Range validation for year_of_make runs on this path too and returns 400.

An update that changes plant_id is refused with the create’s 409 when another live plant in the project uses the new number. An update that leaves the number alone, sends the same number with different case or spaces, or clears it, is never checked. Plants that shared a number before the rule existed stay editable. Prod had three such groups on 2026-09-26, all in the Lacroo Technologies test org.

Archive (DELETE)

DELETE /{plantId} archives the plant (owner ruling 2026-09-26: “a plant archive is the right option”). The row stays; deleted_at is the archive stamp and reaches the wire as archived_at. Display IDs are never reissued, so a restored plant keeps its own.

What archiving means:

  • Its hire ends (owner ruling 2026-09-26: “Yes of course. Archiving a plant ends its open hire.”), in the archive’s own transaction, the way End hire does. Every hire period still running on the archive day ends on that day, in the project’s calendar (inclusive, as End hire’s date is). A hire booked to start after the archive day never began, so it is cancelled. A hire that already ended is left as it is. Each ended hire gets End hire’s audit row, on_site_closed (“ended the hire”), beside the archive’s plant_register_deleted (“archived this plant”).

  • Its automatic dockets stop at once: the flag is turned off and the schedule id cleared in the same transaction, and the Temporal schedule is removed after it commits (best effort; the weekly sweep removes any that survives).

  • Its open alerts close (owner ruling 2026-09-26: “Plant: Yes to all 3.”), in the same transaction: every open plant_register alert of the plant gets resolved_at, and one audit row, alerts_closed (“closed its open alerts”), lists the alert ids and codes. An archived plant raises no new alert, not even one raised while the archive is in flight: the raise checks and locks the plant in its own upsert, so it waits for the archive and then opens nothing. The migration 20260926181053_close_open_alerts_of_archived_plants closed the one alert prod had left open on an already-archived plant.

  • Its hire stays ended. PUT /v1/orgs/{orgId}/projects/{projectId}/hire-periods/{hirePeriodId} answers 409 with error: "plant-archived" when the hire belongs to an archived plant and the update clears hire_end_date or moves it past the archive day, in the project’s calendar: “This plant was archived on 2026-09-03. Its hire must end by that day.” Archiving already ends the hire, and a hire period with an end date is closed to every edit (409 hire-period-closed), so this rule is the backstop for an archived plant whose hire has no end date. It runs inside the hire edit’s own transaction, after that edit has locked the plant row, so an edit racing an archive waits for it and is then refused. The check and the write share one tenant scope (org and project), so a caller from another org reaches neither and gets 404. A new hire cannot be added: the plant routes that open one answer 404, and a hire period created on its own cannot name a plant.

  • Its spend keeps counting. dockets.plant_register_id is never cleared. Every cost tile, report, ERP export stage and CSV export keeps counting the plant’s dockets exactly as before. The KPI query’s docket joins carry no liveness guard, on purpose (pinned by TestKPIQuery_ArchivedPlantDocketsKeepCounting).

  • Its page still opens, labelled Archived and read-only: GET /{plantId}, /hire-history, /recent-dockets and /audit all answer for an archived plant. Every link to it from a docket, report or export resolves.

  • It takes no new work. Every write (PATCH, on-site open/close, auto-gen enable/disable/run-now, recurrence) answers 404 for an archived plant, the scheduler skips it, a new docket cannot be attributed to it, it is not offered in pickers or allocation catalogues, and a duplicated diary’s docket copy drops it.

  • It can be restored (below).

The supplier portal shows archived plants the same way: hidden by default, listed with include_archived=true, flagged archived: true, detail and sub-reads still open.

Restore (POST /{plantId}/restore)

Owner ruling 2026-09-26: “restorable by anyone like engineers admins master admins etc”. restorePlantRegister takes the key that archives a plant, plants.delete (engineers and every admin tier; no new permission). The row scope still applies: a scoped caller who cannot see the plant gets the same 404 as for a missing one. It answers 200 with the plant, live.

What a restore does:

  • Clears the archive and writes one audit row, plant_register_restored (“restored this plant”).
  • Leaves automatic dockets off. A plant archived with them on comes back as if someone had pressed Disable: auto_generate_dockets is cleared, its leftover Temporal schedule is removed and its automation alerts close. Someone turns automation on again when they want it.
  • Back-fills nothing and reopens nothing. No docket is created for the archived days (no schedule is registered or triggered; once automation is turned on again, the catch-up only ever looks at the current day). Hire periods, dockets and allocations are exactly as they were: a hire the archive ended stays ended, and the plant comes back off hire until someone starts a new one.

Idempotent. Restoring a live plant writes nothing and answers 200 with the plant. The route carries the shared Idempotency-Key middleware; web and mobile send a fresh key per confirm, so a refusal is never replayed after it is fixed.

Refused (409) when a live plant in the same project already uses the plant’s register ID or its asset number (plant_id, compared trimmed and case-blind; a value with no letter or digit, such as -, never clashes). The usual cause is the same machine registered again after it was archived. The message names the other plant, for example “Excavator 20t (PR-0040) already uses asset number EX100. Change its asset number or archive it first.” A caller held to their own plant rows is told only “Another live plant already uses asset number EX100.”, since the other plant may be outside their rows. Restores in one project are serialised, so two archived plants that share a number cannot both come back.

Real-time. An archive and a restore are each one UPDATE of the plant row, so trg_plant_register_notify sends one plant_register change notice. It carries archived (the plant’s state after the write, since 20260926092009). Open registers refresh, on web and mobile. An open web plant page refreshes its Hire History, Dockets and Audit tabs on any notice for its plant, and re-renders its header and form when the notice’s archived is not the state it shows; any other plant write leaves the Details form alone, so edits in progress are kept. The mobile plant screen refreshes the plant and its hire history. Both UPDATEs match only a row in the other state, so a no-op archive or restore sends nothing.

On-site lifecycle

MethodPathRole gateRequest bodyResponse
POST/{plantId}/on-site/opencanManagePlantsOpenOnSiteInputPlantRegister
POST/{plantId}/on-site/closecanManagePlantsCloseOnSiteInputPlantRegister
GET/{plantId}/hire-historyplants.view—HirePeriodHistoryList
  • open inserts a hire_periods row, sets is_on_site=true, updates current_hire_period_id.
  • close stamps hire_end_date, flips is_on_site=false, force-disables auto_generate_dockets, and unregisters the Temporal schedule.

Auto-generation lifecycle

MethodPathRole gateRequest bodyResponse
POST/{plantId}/auto-gen/enablecanManagePlants—PlantRegister
POST/{plantId}/auto-gen/disablecanManagePlants—PlantRegister
POST/{plantId}/auto-gen/run-nowmaster_admin—RunNowResult
GET/{plantId}/recent-docketsplants.viewlimitRecentDocketsResult
GET/field-operators?supplierOrgId=…plants.view—FieldOperatorList

Enable

Validates the four invariants (wet-hire, on-site, classification present, defaults present) and the allocation hard-gate. On success registers a Temporal schedule via backend/workers/scheduler.Register with ID plant-register-auto-docket:{plant_id}. Failures return structured 409 with code (classification_missing, dependency_missing, allocation_unconfirmed, …) + action_url + action_label so the UI can render a deep-link CTA.

Disable

Unregisters the Temporal schedule and flips auto_generate_dockets=false. Idempotent — unregistering a non-existent schedule is a no-op.

Run-now

Manually triggers the plant’s schedule outside its cron cadence. Returns an opaque identifier and the schedule ID. Useful for QA and smoke tests.

Dockets on request (BUG-0094)

Method and pathWhat it does
GET /diaries/{diaryId}/plant-optionsDry-hire plant for the diary’s date, each marked on_hire and has_docket.
POST /diaries/{diaryId}/plant-dockets{plant_register_id}. Creates the machine’s docket for the diary’s own date, shift and supervisor. 400 plant-not-on-hire, 409 plant-already-has-docket. Idempotency-Key.
GET /plant-register/{plantId}/docket-options?date=can_create, blocked_reason, and the supervisor, shift and diary the daily job’s rules would use.
POST /plant-register/{plantId}/dockets{date} (today or earlier). Creates the docket as the daily job would, whether or not auto-generation is on. Idempotency-Key.
GET /plant-register/{plantId}/recurrence/backfill-previewFor a proposed rule (frequency, start_date, end_date, interval, days_of_week), the days it would fill, each create, skip_existing, skip_confirmed_diary or skip_no_hire. Writes nothing.

PUT /plant-register/{plantId}/recurrence also takes backfill: {expected_count}, sent after the user confirmed the preview. A different live count answers 409 backfill-count-changed and saves nothing. The run is one Temporal workflow per plant (PlantRegisterBackfill) and writes one auto_gen_backfill audit row.

Bulk operations

MethodPathRole gateRequest bodyResponse
POST/bulk/on-sitecanManagePlantsBulkOnSiteInputBulkOnSiteResult
POST/bulk/auto-gencanManagePlantsBulkAutoGenInputBulkAutoGenResult

Both endpoints process IDs in a single transaction but return per-row status so the UI can render partial successes. Example BulkAutoGenResult.rows[*]:

{
  "plant_id": "…",
  "status": "enabled" | "skipped_allocation_unconfirmed" | "skipped_invalid_type" | "skipped_off_site" | "already_enabled" | "error",
  "message": "human-readable reason"
}

Bulk edit (bulkEditPlantRegister, bulkEditPlantRegisterByFilter)

MethodPathPermissionRequest bodyResponse
POST/bulk-editplants.edit (+ plants.manage_hire_periods when a hire date is sent)BulkEditPlantRegisterRequestBulkEditResult
POST/bulk-edit-by-filtersameBulkEditPlantRegisterByFilterRequestBulkEditResult

changes takes default_supervisor_user_id, default_shift_type, auto_docket_recurrence, link_project_id, hire_start_date and hire_end_date; only the keys sent change, and any other key is a 400. The hire dates edit the plant’s current hire period. Each plant passes the caller’s plants scope (others come back not_found) and the single edits’ own rules (updatePlantRegister for the plant, updateHirePeriod for the hire), so a plant is refused with the same words the single edit would use. filter takes the list’s own filters (hire_type, supplier_org_id, is_on_site, auto_generate_dockets, unassigned_supervisor, include_archived, search); more than 500 matches is a 422. Both routes take an Idempotency-Key. The result’s action_id is undoable through undoAction; undo refuses a hire it would reopen or move onto another hire of the same plant, and never turns auto-dockets on or off. An archived plant is never edited: named by id, or listed through include_archived, it comes back refused with “This plant is archived. Restore it first.” (the single update answers it 404). Undo puts back nothing on one or on its hires, with the same words. The ledger tier is financial (hire dates move what the monthly reconciliation bills).

Dashboard KPIs

MethodPathGateResponse
GET/kpiplants.viewPlantRegisterKPI
GET/{plantId}/auditplants.viewPlantRegisterAuditTrail

One round-trip backs the five tiles on /admin/plant-register:

TileFieldMeaning
M1on_sitePlants whose current hire window covers the project-local date. Sub-label uses total.
M2auto_gen_on_site / on_siteAuto-generation enabled AND on site. Rendered as a percentage plus N of M.
M3plant_cost_mtd_attributedMonth-to-date SUM(grand_total) over reviewed + confirmed dockets attributed to a registered plant.
M4plant_cost_last_month_attributedSame, previous calendar month.
M5on_site_unassigned_supervisorOn site with no default supervisor, regardless of auto-gen state. Links to the list filtered by is_on_site=true&unassigned_supervisor=true.

Counters are project-wide by design. The endpoint gates on plants.view but applies no row scope — a row-scoped supervisor legitimately sees tiles larger than their own filtered list, which is why every sub-label says project-wide.

The cost tiles sum grand_total, not equipment_total (CTO ruling D4). The population is the D2 attribution primitive: dockets carrying dockets.plant_register_id. The consequence is accepted — an attributed docket’s wet-hire operator labour and every other line on it are included, so the figure can exceed the project’s plant-line spend.

Because a dollar ratio is incoherent under grand_total (it can exceed 100%), coverage is reported as a count: plant_dockets_*_attributed of plant_dockets_*_total, where the denominator is the plant-docket population on the same window — dockets carrying plant-line spend or already attributed. The union matters: every auto-generated plant docket currently lands unpriced, so an equipment_total > 0-only denominator would not be a superset of the numerator and the tile could read “7 of 3”.

Expect the cost tiles to under-read while attribution converges. The auto-docket worker wires Pricing: nil, so the dockets this feature generates are $0 until they are repriced on a status transition, and historic manual dockets carry no attribution unless the deterministic backfill matched them. The coverage sub-label is the honesty mechanism — it distinguishes “$0 because nothing was spent” from “$0 because nothing is attributed yet” — and must not be hidden.

Timezone — two opposite rules in one query. The on-site counters compare hire dates against now() on a UTC-pinned session and therefore must convert via AT TIME ZONE COALESCE(NULLIF(p.timezone, ''), 'Australia/Sydney'). The cost windows compare dockets.date, which is already a project-local calendar date, and therefore must not convert — the month boundaries are computed in Go against projects.timezone and passed as parameters. CURRENT_DATE appears nowhere: on a UTC-pinned session it buckets a Sydney project into the previous month for the first 10–11 hours of the 1st. Windows are half-open (>= start AND < next_start) so a boundary date can never be double-counted.

Three legacy fields — auto_gen_active, auto_dockets_today, mtd_equipment_cost — are still emitted, marked deprecated, for exactly one release so pre-OTA mobile bundles keep rendering. They mirror the canonical values (auto_dockets_today kept its meaning but lost its UTC bug). Read the canonical names.

Archived plants keep counting in the cost tiles. The M3/M4 docket joins reach dockets through the attributed plant with no deleted_at guard, so archiving a plant never moves a cost figure. Only the plant counters (total, M1, M2, M5) count live plants, because an archived plant is not on the register today.

/{plantId}/audit returns audit_logs rows for the plant. audit_logs has no project_id column, so the projectId in the path is enforced with an EXISTS guard against plant_register — the plant must live in the project. Before that guard the handler parsed projectId and discarded it, so a member of project B could read project A’s plant trail by pasting the plant id into their own project’s URL.

System Alerts

Plant Health Alerts surface through the reusable system_alerts table and its endpoints:

MethodPathRole gateResponse
GET/v1/orgs/{orgId}/system-alertsany org memberSystemAlertList
GET/v1/orgs/{orgId}/system-alerts/countsany org memberSystemAlertCounts
GET/v1/orgs/{orgId}/projects/{projectId}/system-alerts?alert_kind=plant_registerany project memberSystemAlertList

The sticky <AlertsHeader /> in the admin layout polls the counts endpoint via TanStack Query and is invalidated in real time when a system_alerts row changes (via the project_changes Postgres NOTIFY → WebSocket hub).

CSV export

MethodPathRole gateQueryResponse
GET/export.csvplants.editsame filters as GET /, except archived plants are always includedtext/csv stream

Rows are sorted by display_id ASC so two identical queries produce bit-identical CSV bodies — verified by the csv_export_test.go snapshot test, which byte-compares the response against testdata/export_fixture.csv. The column list in csv_export.go is an allow-list, so a new plant_register column never reaches the export by accident.

The 17 columns, in canonical order:

display_id, plant_id, name, manufacturer, model, year_of_make, hire_type, supplier_name, equipment_rate_name, classification_name, is_on_site, hire_start, hire_end, auto_gen, mtd_equipment_hours, mtd_equipment_cost, status.

status is active or archived. Archived plants are always in the file, whatever the list’s include_archived says: the file carries MTD cost per plant, and archived plants’ dockets keep counting. status is appended last so no existing column moves.

Supplier portal (read-only)

MethodPathRole gateResponse schema
GET/v1/supplier-portal/plant-registersupplier memberPortalPlantRegisterList
GET/v1/supplier-portal/plant-register/{plantId}supplier memberPortalPlantRegister
GET/v1/supplier-portal/plant-register/{plantId}/hire-historysupplier member{ items: PlantRegisterHirePeriodHistoryItem }
GET/v1/supplier-portal/plant-register/{plantId}/docketssupplier member{ items: PortalPlantDocket }

Every query filters by supplier_org_id = session.supplier_org_id server-side. Cross-tenant access is covered by an integration test per spec §14.

Portal field set (sealed)

Both backing queries are SELECT pr.*, so every new plant_register column reaches the Go layer automatically. What the supplier actually sees is the hand-maintained portalPlantItem allow-list in supplierportal/plantregister_handler.go, and that set is now CI-sealed by plantregister_fieldset_test.go: the tests assert the exact serialized key set (equality, not subset), scan for sentinel values planted in every withheld column, and fail if any newly-added column arrives unclassified.

year_of_make is portal-visible (the year of the asset is the supplier’s own data). archived (a boolean) is portal-visible; the deleted_at timestamp behind it is withheld. default_dayworks_item_id, the auto-docket cost code / schedule / recurrence, the default operator + supervisor, metadata, custom_field_values and every internal FK are withheld.

Error shapes

All error responses follow the platform-wide Problem shape with a code field. Plant-register-specific codes:

CodeHTTPMeaning
plant_not_found404Not in caller’s project, or a write to an archived plant.
CONFLICT (restore)409A live plant already uses the restored plant’s register ID or asset number. The message names it.
immutable_field400Patch attempted on org_id / project_id / supplier_org_id / contract_id / hire_type.
autogen_classification_required422Wet-hire invariant breached on enable or update.
autogen_offsite422Enable requested while is_on_site=false.
autogen_defaults_missing422One or more of operator / supervisor / shift is NULL.
allocation_unconfirmed409Hard-gate blocked. details carries {action_url, action_label} deep link.
schedule_register_failed502Temporal returned non-retryable error during scheduler.Register. Surface to admin; log full trace ID.

Business-rule validation failures on POST / and PATCH /{plantId} — “contract not found”, “rate line does not belong to contract”, “year_of_make must be between 1900 and 2100” — return 400 with the caller-safe message. They previously surfaced as 500 + a Sentry capture because the create/update handlers had no ErrValidation arm.

Observability

Every handler calls observability.SetSpanEntityID(ctx, "plant_register", id) + observability.SpanEventWithFields(...). Sentry tags: plant_register_id, supplier_org_id, contract_id. Axiom dashboards track:

  • Schedule registrations / day, active schedule count.
  • Auto-docket success rate (target ≥ 99.5%), skipped-by-reason breakdown.
  • Activity p50 / p99 latency; retry distribution.
  • Monitor: page when any plant has auto_docket_last_status='failed' for > 1 hour.