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 callauthz.Canwithplants.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 bysupplier_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
| Method | Path | Gate | Request body | Response schema |
|---|---|---|---|---|
GET | / | plants.view¹ | query filters (see below) | PlantRegisterList |
POST | / | plants.create | PlantRegisterCreate | PlantRegister |
GET | /{plantId} | plants.view¹ | — | PlantRegister |
PATCH | /{plantId} | plants.edit | PlantRegisterUpdate | PlantRegister |
DELETE | /{plantId} | plants.delete | — | 204 |
POST | /{plantId}/restore | plants.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|drysupplier_org_id— UUIDis_on_site— booleanauto_generate_dockets— booleanunassigned_supervisor— boolean. One-directional:truenarrows 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 withis_on_site=trueset as well so the list shows exactly the population the tile counted.include_archived— boolean.trueadds archived plants, sorted after every live plant, each witharchived_atset. Absent orfalselists 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 onname,plant_id,manufacturer,modelcursor,limit— offset pagination (limitis 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, andrate_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’splant_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_registeralert of the plant getsresolved_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 migration20260926181053_close_open_alerts_of_archived_plantsclosed 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 witherror: "plant-archived"when the hire belongs to an archived plant and the update clearshire_end_dateor 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 (409hire-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_idis 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 byTestKPIQuery_ArchivedPlantDocketsKeepCounting). -
Its page still opens, labelled Archived and read-only:
GET /{plantId},/hire-history,/recent-docketsand/auditall 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_docketsis 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
| Method | Path | Role gate | Request body | Response |
|---|---|---|---|---|
POST | /{plantId}/on-site/open | canManagePlants | OpenOnSiteInput | PlantRegister |
POST | /{plantId}/on-site/close | canManagePlants | CloseOnSiteInput | PlantRegister |
GET | /{plantId}/hire-history | plants.view | — | HirePeriodHistoryList |
openinserts ahire_periodsrow, setsis_on_site=true, updatescurrent_hire_period_id.closestampshire_end_date, flipsis_on_site=false, force-disablesauto_generate_dockets, and unregisters the Temporal schedule.
Auto-generation lifecycle
| Method | Path | Role gate | Request body | Response |
|---|---|---|---|---|
POST | /{plantId}/auto-gen/enable | canManagePlants | — | PlantRegister |
POST | /{plantId}/auto-gen/disable | canManagePlants | — | PlantRegister |
POST | /{plantId}/auto-gen/run-now | master_admin | — | RunNowResult |
GET | /{plantId}/recent-dockets | plants.view | limit | RecentDocketsResult |
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 path | What it does |
|---|---|
GET /diaries/{diaryId}/plant-options | Dry-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-preview | For 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
| Method | Path | Role gate | Request body | Response |
|---|---|---|---|---|
POST | /bulk/on-site | canManagePlants | BulkOnSiteInput | BulkOnSiteResult |
POST | /bulk/auto-gen | canManagePlants | BulkAutoGenInput | BulkAutoGenResult |
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)
| Method | Path | Permission | Request body | Response |
|---|---|---|---|---|
POST | /bulk-edit | plants.edit (+ plants.manage_hire_periods when a hire date is sent) | BulkEditPlantRegisterRequest | BulkEditResult |
POST | /bulk-edit-by-filter | same | BulkEditPlantRegisterByFilterRequest | BulkEditResult |
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
| Method | Path | Gate | Response |
|---|---|---|---|
GET | /kpi | plants.view | PlantRegisterKPI |
GET | /{plantId}/audit | plants.view | PlantRegisterAuditTrail |
One round-trip backs the five tiles on /admin/plant-register:
| Tile | Field | Meaning |
|---|---|---|
| M1 | on_site | Plants whose current hire window covers the project-local date. Sub-label uses total. |
| M2 | auto_gen_on_site / on_site | Auto-generation enabled AND on site. Rendered as a percentage plus N of M. |
| M3 | plant_cost_mtd_attributed | Month-to-date SUM(grand_total) over reviewed + confirmed dockets attributed to a registered plant. |
| M4 | plant_cost_last_month_attributed | Same, previous calendar month. |
| M5 | on_site_unassigned_supervisor | On 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:
| Method | Path | Role gate | Response |
|---|---|---|---|
GET | /v1/orgs/{orgId}/system-alerts | any org member | SystemAlertList |
GET | /v1/orgs/{orgId}/system-alerts/counts | any org member | SystemAlertCounts |
GET | /v1/orgs/{orgId}/projects/{projectId}/system-alerts?alert_kind=plant_register | any project member | SystemAlertList |
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
| Method | Path | Role gate | Query | Response |
|---|---|---|---|---|
GET | /export.csv | plants.edit | same filters as GET /, except archived plants are always included | text/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)
| Method | Path | Role gate | Response schema |
|---|---|---|---|
GET | /v1/supplier-portal/plant-register | supplier member | PortalPlantRegisterList |
GET | /v1/supplier-portal/plant-register/{plantId} | supplier member | PortalPlantRegister |
GET | /v1/supplier-portal/plant-register/{plantId}/hire-history | supplier member | { items: PlantRegisterHirePeriodHistoryItem } |
GET | /v1/supplier-portal/plant-register/{plantId}/dockets | supplier 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:
| Code | HTTP | Meaning |
|---|---|---|
plant_not_found | 404 | Not in caller’s project, or a write to an archived plant. |
CONFLICT (restore) | 409 | A live plant already uses the restored plant’s register ID or asset number. The message names it. |
immutable_field | 400 | Patch attempted on org_id / project_id / supplier_org_id / contract_id / hire_type. |
autogen_classification_required | 422 | Wet-hire invariant breached on enable or update. |
autogen_offsite | 422 | Enable requested while is_on_site=false. |
autogen_defaults_missing | 422 | One or more of operator / supervisor / shift is NULL. |
allocation_unconfirmed | 409 | Hard-gate blocked. details carries {action_url, action_label} deep link. |
schedule_register_failed | 502 | Temporal 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.