Plant Health Alerts
Plant Health Alerts is the first consumer of the reusable system alerts primitive. It watches each plant-register row for state the admin needs to act on and surfaces those signals through a sticky <AlertsHeader /> mounted in the admin layout. Alerts are self-clearing — nobody manually acknowledges them — and in-app only for MVP.
Architecture in one paragraph
The system_alerts table (migration added in Slice 5) stores currently-open alerts. Each row has an alert_kind (MVP: only plant_register), an alert_code (six for Plant Register, listed below), and a severity (critical / warning / info). A row is open while resolved_at IS NULL. Writes come from (a) the CreateAutoDocket activity when it hits a skip branch, (b) the allocation-change listener, and (c) project-member removal triggers. Resolution is event-driven only (allocation confirmed, successful fire, plant edited, plant archived: archiving closes every open alert of the plant in the same transaction). Plant alerts are raised only while the plant is live, checked in the raise’s own statement, so a raise racing an archive leaves nothing open.
MVP note: there is no periodic Temporal sweeper. The original design called for a 10-minute reconciliation sweeper as belt-and-braces coverage, but it was not built — resolution relies entirely on the event-driven paths below. If a NOTIFY/listener is missed (e.g. a Neon failover dropping a LISTEN connection), an alert can linger until the next matching event; add the sweeper if that proves necessary in production.
The primitive flows:
writer ──▶ system_alerts row (NOTIFY project_changes)
│
▼
WebSocket hub ──▶ TanStack Query invalidate(["system-alerts"])
│
▼
<AlertsHeader /> ──▶ renders thin bar with severity counts + CTAThe 6 MVP alert codes
All under alert_kind='plant_register'.
| Code | Severity | Fires when | Clears when |
|---|---|---|---|
allocation_invalidated | critical | Allocation for the plant’s equipment_rate OR classification loses confirmed_at after auto-generation was enabled. Auto-pauses the schedule. | Admin re-confirms the allocation (event-driven listener clears the row). |
auto_docket_fire_failed | critical | The PlantRegisterAutoDocket workflow terminally fails after all retries (MaximumAttempts=3). auto_docket_last_status='failed'. | Next successful fire OR admin disables auto-generation. |
operator_missing | critical | default_operator_user_id loses its worker_project_assignment for this project (e.g. admin unassigned or worker verification expired). | Admin picks a new default operator. |
supervisor_removed | critical | Legacy manned plants: default_supervisor_user_id is not set, or is no longer a supervisor on the project. Rate-line plants (warning): the default supervisor is set but is no longer a supervisor on the project. See the note below. | Admin picks a new default supervisor, OR a rate-line run stamps a qualifying one. |
shift_config_missing | critical | default_shift_type no longer has a matching entry in projects.shift_config JSONB (admin removed the shift from the project). | Admin re-adds the shift timing OR picks a different shift for the plant. |
allocation_pending | warning | Plant exists with classification_id + equipment_rate_id allocations but confirmed_at IS NULL since creation. Unlike allocation_invalidated, this one fires without auto-generation ever being enabled. | Admin confirms the allocation. |
Severity ordering is critical → warning → info. The header counts them separately and the plant-register page’s Alert Section lists critical rows first, then warnings.
supervisor_removed on rate-line plants
Supervisor is optional on rate-line (unmanned) auto-generating plants. A plant with no default
supervisor still generates its dockets; they simply land in the Docket Workbench with
primary_supervisor_id NULL and no diary binding. That is a supported configuration, not a fault, so
a plant with no default supervisor raises nothing. Legacy manned plants — which genuinely cannot
materialise without a supervisor — keep the alert unchanged. Full semantics:
Scheduler → supervisor is OPTIONAL.
A plant whose default supervisor is set but is no longer a supervisor on the project (they left
the project, were deactivated, or their role there changed) is different: the admin chose someone the
dockets can no longer carry (Docket Supervisor). The run still drafts
the docket, with no supervisor, and raises supervisor_removed as a warning. The next run that
stamps a qualifying supervisor clears it; so does picking a new default supervisor.
The intended surfacing for supervisor-less plants is not an alert at all:
- the plant carries a non-blocking amber notice on its automation card and in the Add Plant sheet;
- the generated dockets are findable in the workbench by their
-AUTO-number; - and the only proactive signal once such a docket moves through the workflow is
docket_no_diary, which is raised on docket SUBMIT (in the dockets module, not this table’splant_registerkind). A draft orphan raises nothing at all — by design, since drafting one every scheduled day is exactly what the feature is for. Office users work these dockets from the workbench; assigning a supervisor on an individual docket re-binds it to that supervisor’s diary.
The sticky <AlertsHeader /> primitive
Mounted once in apps/web/src/app/admin/layout.tsx, above every admin page. The component:
- Queries the active-alerts list (
GET /v1/orgs/{orgId}/system-alerts) via TanStack Query and groups by kind/severity client-side. (A/system-alerts/countsendpoint exists in the API but is not used by the header.) - Relies on the
project_changesWebSocket channel — the sharedws-providerinvalidates["system-alerts", orgId]on anysystem_alertsevent. No client-side polling. - Renders a thin red / amber bar when
(critical + warning) > 0, with severity-separated pills and a “View” CTA. - Stays fully hidden when no alerts are open — no empty state, no layout jitter.
Clicking View routes through a small client-side registry (apps/web/src/lib/alerts/kinds.ts) keyed by alert_kind. For plant_register it navigates to /admin/plant-register with the alert section scrolled into view and the relevant rows highlighted.
The Alert Section on the plant-register list page is a separate component that reads the full alert list (not just counts), sorted by severity then recency. Each row: severity pill · plant name · one-line cause · action button (deep link from action_url / action_label). Rows disappear in real time when the underlying alert’s resolved_at stamps.
Self-clearing logic
Resolution is event-driven. (The originally-planned periodic sweeper was not built — see the MVP note above.)
Event-driven resolution
- Allocation confirm → listener clears
allocation_invalidatedandallocation_pendingfor the affected plants. - Successful auto-docket fire →
CreateAutoDocketactivity clearsauto_docket_fire_failedfor the plant. - Admin edit on plant row (new operator / supervisor / shift) → Go service clears the corresponding dependency alert.
Extending alert_kinds (the registry pattern)
Adding a new alert kind for a future feature is a three-touchpoint change:
-
DB: extend the
alert_kindCHECK constraint insystem_alertsvia a new migration:ALTER TABLE system_alerts DROP CONSTRAINT system_alerts_alert_kind_check, ADD CONSTRAINT system_alerts_alert_kind_check CHECK (alert_kind IN ('plant_register', 'my_new_kind')); -
Backend handler: add a package under
backend/api/internal/systemalerts/kinds/{my_new_kind}that knows how to (a) emit alerts for the kind, and (b) evaluate the clear predicate for the sweeper. Follow theplantregisterpackage as the template —alerts_listener.gofor event-driven,alert_emitter.gofor the writer interface. -
Frontend registry: add an entry to
apps/web/src/lib/alerts/kinds.ts:import type { AlertKindConfig } from "./types"; export const ALERT_KINDS: Record<string, AlertKindConfig> = { plant_register: { icon: "plant", routeTo: (alert) => `/admin/plant-register#alert-${alert.id}`, formatBody: (alert) => alert.body ?? DEFAULT_PLANT_BODIES[alert.alert_code], }, my_new_kind: { icon: "…", routeTo: (alert) => `/admin/…`, formatBody: (alert) => alert.body ?? "…", }, };
The <AlertsHeader /> and Alert Section components are kind-agnostic — they iterate the registry and render whatever’s there. No component changes required for new kinds.
What alerts deliberately do NOT do
- No email / SMS. MVP is in-app only. Email delivery is a follow-up via the existing Postmark + Temporal activity pipeline.
- No manual acknowledgement. Alerts either match their clear predicate and self-resolve, or they stay open. This prevents “acknowledged but still broken” drift.
- No cross-org alert visibility. A supplier org never sees an HC org’s alerts, and vice versa — queries strictly filter by
org_id = session.org_id. - No alert history UI in MVP. Resolved alerts stay in
system_alertsfor audit, but there’s no UI to browse them yet. Add in Tier 2 if requested.
Observability
Every alert raise / dismiss / resolve emits an OpenTelemetry span event
(system_alert_raised / system_alert_dismissed / system_alert_resolved,
with the alert id set as the span entity id) that streams to Axiom. There is no
dedicated audit_logs row for system-alert lifecycle events in the MVP — the
span events are the audit trail. Axiom dashboards can track alert volume by code
and time-to-resolution from these events.