ModulesPlant RegisterPlant Health Alerts

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 + CTA

The 6 MVP alert codes

All under alert_kind='plant_register'.

CodeSeverityFires whenClears when
allocation_invalidatedcriticalAllocation 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_failedcriticalThe PlantRegisterAutoDocket workflow terminally fails after all retries (MaximumAttempts=3). auto_docket_last_status='failed'.Next successful fire OR admin disables auto-generation.
operator_missingcriticaldefault_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_removedcriticalLegacy 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_missingcriticaldefault_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_pendingwarningPlant 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’s plant_register kind). 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:

  1. Queries the active-alerts list (GET /v1/orgs/{orgId}/system-alerts) via TanStack Query and groups by kind/severity client-side. (A /system-alerts/counts endpoint exists in the API but is not used by the header.)
  2. Relies on the project_changes WebSocket channel — the shared ws-provider invalidates ["system-alerts", orgId] on any system_alerts event. No client-side polling.
  3. Renders a thin red / amber bar when (critical + warning) > 0, with severity-separated pills and a “View” CTA.
  4. 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_invalidated and allocation_pending for the affected plants.
  • Successful auto-docket fire → CreateAutoDocket activity clears auto_docket_fire_failed for 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:

  1. DB: extend the alert_kind CHECK constraint in system_alerts via 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'));
  2. 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 the plantregister package as the template — alerts_listener.go for event-driven, alert_emitter.go for the writer interface.

  3. 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_alerts for 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.