Scheduler Primitive
backend/workers/scheduler is a feature-agnostic wrapper around Temporal Schedules. It was introduced alongside Plant Register (first consumer) but belongs to the platform — every future cron-shaped workflow should register via this API rather than talking to client.ScheduleClient() directly.
The package:
- Validates
Specvalues locally before any Temporal call. - Forces callers to pick a deterministic schedule ID.
- Delegates cron parsing, timezone handling, and DST arithmetic to Temporal server-side.
- Emits
OpenTelemetryspan events on every lifecycle transition. - Provides a
Reconcilehelper that sweeps orphaned schedules on worker startup and on a weekly schedule.
Public API
// backend/workers/scheduler/scheduler.go
type Client interface {
Register(ctx context.Context, spec Spec) error
Update(ctx context.Context, id string, spec Spec) error // cron / tz / jitter only
Pause(ctx context.Context, id, reason string) error // reason persists as Temporal note
Unpause(ctx context.Context, id string) error
Trigger(ctx context.Context, id string) (string, error) // fires outside cron
Unregister(ctx context.Context, id string) error // idempotent on NotFound
}
func New(tc client.Client) Client
// Package-level helper — feature-agnostic reconciliation.
func Reconcile(
ctx context.Context,
tc client.Client,
expectedIDs []string,
) (ReconcileReport, error)Types
type Spec struct {
ID string // deterministic, caller-owned (see ID convention below)
TaskQueue string // Temporal task queue — workflow+activities run here
WorkflowType string // registered workflow type name
WorkflowArgs any // single input struct; Temporal persists this
Calendar CalendarSpec
Note string // human-readable; visible in Temporal UI
}
type CalendarSpec struct {
CronExpr string // 5-, 6-, or 7-field Temporal cron expr
Timezone string // IANA zone, e.g. "Australia/Brisbane"
Jitter time.Duration // optional random offset in [0, Jitter)
}
type ReconcileReport struct {
OrphansUnregistered int
Missing []string // expected IDs absent from Temporal; caller heals
}Schedule ID naming convention
Schedule IDs are the shared namespace between Temporal, Axiom logs, and the DB row that declares the schedule exists. The convention:
{feature}:{entity_id}Examples:
plant-register-auto-docket:8f0c97c5-…compliance-email-weekly:74d16ba1-…(hypothetical future consumer)
Why deterministic? Temporal generates workflow run IDs as {Spec.ID}-{action-start-time}, which gives us natural dedupe: two fires at the same instant for the same entity collapse into one workflow execution.
Project-timezone handling and DST
Temporal parses the CronExpr in the Timezone you provide. Because cron is evaluated server-side in that zone, DST transitions “just work” — a 5 7 * * 1-5 schedule fires at 07:05 in the local wall clock before and after the spring / autumn transition without any caller logic.
Plant Register’s Slice 3 ships a DST test matrix in scheduler_test.go covering Sydney, Brisbane, Santiago, and Tokyo. If a future feature adds a new timezone expectation, add it to that matrix so regressions are caught at CI time.
Caveats:
- Non-existent wall-clock times on the spring-forward boundary are skipped (Temporal docs: “no fire for times that do not exist”).
- Ambiguous wall-clock times on the autumn fall-back boundary fire once. If your business logic needs to fire during both the pre- and post-DST hour, you must split into two schedules.
Idempotency guarantees
Unregisteron a missing schedule is a no-op.isNotFoundaccepts both the typed*serviceerror.NotFoundand plain-text “not found” errors, so reconciliation flows are fully idempotent.- Workflow run IDs are deterministic per fire. Temporal dedupes multiple fires of the same schedule at the same instant.
- Callers own their own DB idempotency. The
CreateAutoDocketactivity, for instance, runs aSELECT ... WHERE plant_register_id=$1 AND date=$2 AND auto_generated=trueguard before inserting — a duplicate run returnsSkippedDuplicate. Callers building new features should bake in an equivalent check.
Example — registering a weekly compliance-email schedule
import (
"context"
"time"
"go.temporal.io/sdk/client"
"github.com/Lacroo-Technologies-Pty-Ltd/lacroo-pro/backend/workers/scheduler"
)
func RegisterComplianceEmail(
ctx context.Context,
sched scheduler.Client,
orgID string,
orgTimezone string,
) error {
spec := scheduler.Spec{
ID: "compliance-email-weekly:" + orgID,
TaskQueue: "lacroo-default",
WorkflowType: "ComplianceEmailWeekly",
WorkflowArgs: ComplianceEmailArgs{OrgID: orgID},
Calendar: scheduler.CalendarSpec{
CronExpr: "0 8 * * 1", // Mondays 08:00 in the org's local time
Timezone: orgTimezone, // e.g. "Australia/Sydney"
Jitter: 5 * time.Minute,
},
Note: "Weekly compliance summary email for org " + orgID,
}
return sched.Register(ctx, spec)
}Callers should keep a DB row that authoritatively lists every schedule they expect to exist. On worker startup, assemble the list of expected IDs and pass it to Reconcile:
expected, err := queries.ListExpectedComplianceScheduleIDs(ctx)
if err != nil {
return err
}
report, err := scheduler.Reconcile(ctx, temporalClient, expected)
if err != nil {
return err
}
for _, missingID := range report.Missing {
// re-register from DB row
}Worker registration pattern
The scheduler package does not register workflow functions with the Temporal worker — the caller does. The intended wiring is:
w := worker.New(tc, "lacroo-default", worker.Options{})
// Plant Register — workflows + activities registered by the caller.
w.RegisterWorkflowWithOptions(
plantregister.PlantRegisterAutoDocket,
workflow.RegisterOptions{Name: "PlantRegisterAutoDocket"},
)
w.RegisterActivity(activities.CreateAutoDocket)
// Scheduler registration happens at a different layer — not on the worker.Keeping those concerns separate means the scheduler package stays a pure value-in / value-out API, testable without a live Temporal broker.
Reconciler usage
Call scheduler.Reconcile in two places:
- Worker startup — once per boot. Catches any schedules that were registered but whose owning DB row was later deleted while the worker was offline.
- Weekly Temporal schedule — register a meta-schedule (e.g.
scheduler-reconcile:{namespace}) that fires every Monday 04:00 UTC. Its workflow body just callsReconcilewith the current expected list. This heals long-running worker processes that never reboot.
ReconcileReport.Missing carries IDs that were expected but absent. The caller decides how to heal — typically by re-registering from the authoritative DB row, since that row is the source of truth for the schedule’s existence.
Per-delete failures during reconciliation do not abort the sweep; they are logged as span events and re-attempted next invocation.
Plant Register auto-generation: supervisor is OPTIONAL
A plant is either rate-line era (it carries a rate_line_id) or legacy manned (it does not).
Every plant created since the rate-engine cutover is rate-line era. The two shapes materialise
through different code paths and have different requirements — and the guard forks on that key at
three separate layers, which must stay in lockstep:
| Layer | Rate-line plant | Legacy manned plant |
|---|---|---|
DB chk_pr_auto_defaults | default_shift_type only | operator and supervisor and shift |
Go EnableAutoGen | shift + operator if the hire is wet | wet hire + classification + operator/supervisor/shift |
Web invariantsOk (automation card / Add Plant sheet) | shift + operator if the hire is wet | all four |
Changing one without the others either strands the UI (the client blocks what the server allows) or mints 500s (the server writes a state the CHECK rejects).
A set supervisor must still be a supervisor on the project
The default supervisor, when there is one, is stamped on each auto-docket only while they are a
supervisor on the project (Docket Supervisor). The check runs on each
fire. A rate-line plant whose default supervisor no longer qualifies drafts its docket with no
supervisor and raises supervisor_removed (warning); a legacy manned plant skips the run and
raises it as critical, exactly as when no supervisor is set. The stored default is never changed by
the run.
Wet hire needs an operator to ARM, not to exist
A wet plant is registered with or without a driver — the machine is on hire either way, and holding the register row hostage to a hiring decision kept real plant off the register. What waits on the operator is auto-generation: a wet auto docket bills the machine and the person running it, so there is nobody to record without one.
This is the one place the enable gate is deliberately stricter than the fire path, and it is declared as such:
plantautogen.Missing()— the FIRE PATH’s list. Unchanged: an already-armed wet plant with no operator keeps minting its machine-only docket, because a recorded half-day beats no day at all.plantautogen.MissingForEnable()—Missing()plus this rule. A strict superset, so the gate can never be looser than the fire path. OnlyenableAutoGenCorereads it.
PATCH /plant-register/{plantId} refuses any edit that would leave an ALREADY-ARMED rate-line plant
wet with no operator — for the same reason, and evaluated on the post-patch shape rather than the
stored one. Two edits reach it: clearing default_operator_user_id, and flipping hire_type to
wet on an armed dry plant that never had an operator. Turn auto-gen off first, or name an operator.
What a supervisor-less plant produces
The unmanned materialiser bills one machine line off the contract rate line and creates no
docket_worker, so it never needs an operator or a classification — and it no longer needs a
supervisor either. When default_supervisor_user_id is NULL the generated docket is inserted with:
primary_supervisor_id= NULLdiary_id= NULLdocket_number={project_prefix}-AUTO-{NNNN}
shift_type is still written, so the docket is deliberately “half-tuple”.
Where it does NOT show up: any supervisor’s diary. A NULL primary_supervisor_id with a NULL
diary_id fails every supervisor-relevant arm of the docket RBAC predicate — primary supervisor,
associated supervisor, and under-my-diary — so no supervisor sees it in the workbench, in any diary,
in Day Reports, or in the diaries-list counters. The diary-attach triggers cannot reach it either:
attach_docket_to_existing_diary() returns early on a NULL supervisor, and the diary-side back-fill
compares with =, which NULL never satisfies.
Where it DOES show up — three surfaces, not one:
- the Docket Workbench for users whose dockets scope is All (admin / office);
- the Docket Workbench for users on the
engineer_cost_codesscope arm. This one is easy to miss: that arm’s first branch is “this docket has no cost-coded activity hours yet”, and a freshly generated docket has zerodocket_activity_hoursrows, so every cost-code engineer sees it until it is coded. The behaviour long pre-dates supervisor-optional generation; this path just makes it reachable for a new class of docket; - the plant’s own Dockets tab (admin web + mobile plant detail), backed by
ListRecentDocketsForPlant, which applies no docket-scope gate at all. Every auto-generated docket carries aplant_register_id, so it always qualifies.
Nothing here leaks to a user who could not already see the plant or the project — but the UI copy
must name surfaces 1–3, not claim “the workbench only”. Both statements above were verified against
the real scope predicate and a real migrated database in
docs/audit/2026-08-04-plant-register-recon/10-workbench-visibility-proof.md.
The AUTO- token is the office user’s visual cue that a row is system-generated and unowned; the
workbench has no “unassigned supervisor” filter.
Why a different display-id formula. The normal formula
{prefix}-{SUPERVISOR_INITIALS}-{NNNN} is backed by display_id_counters, whose primary key
includes supervisor_id UUID NOT NULL REFERENCES users(id). It cannot run without a supervisor, and
the Temporal activity — unlike the interactive create-docket path — has no authenticated actor to
substitute. Supervisor-less dockets therefore mint from a per-project ticker
(projects.auto_docket_ticker_count), the same pattern as the daywork-sheet, issue and plant
tickers. docket_number carries no unique index, so a collision with real initials (“Aubrey Tobin”
→ AUTO) is cosmetic.
Assigning a supervisor later applies to FUTURE dockets only
There is no retro-bind. Setting default_supervisor_user_id on the plant changes what the next
fire stamps; it issues no docket UPDATE and no scheduler call, and the diary back-fill trigger cannot
reach the existing orphans either — its predicate is primary_supervisor_id = NEW.supervisor_id,
and NULL = <uuid> is never true under SQL three-valued logic.
This is deliberate, for two reasons:
- Those orphans may span dates before the new supervisor had any involvement. Injecting them into that person’s diaries rewrites the diary record and silently grants them scope-level visibility over historical dockets — supervisor assignment is an access grant.
- A per-docket escape hatch already exists and already works: setting the primary supervisor on the docket re-fires the attach-to-diary trigger and binds it to that supervisor’s diary the moment one matches.
The helper copy in the UI states this explicitly, and must keep doing so.
Clearing a supervisor
PATCH .../plant-register/{plantId} treats default_supervisor_user_id as tri-state: omit the
key to leave it alone, send null or "" to clear it, send a uuid to set it. Clearing is what makes
“this plant deliberately has no supervisor” reachable from the UI at all.
Alerts on this path
supervisor_removed is not raised (or cleared) by the rate-line materialiser any more — an
absent supervisor is a legal configuration there, not a fault. The legacy manned path still raises
it. See Plant Health Alerts.
There are TWO schedule-registration call sites
scheduler.Register used to have exactly one caller: Service.EnableAutoGen, reached from
POST /plant-register/{plantId}/auto-gen/enable. Creating a plant with automation therefore took
two round trips and a detour through the plant’s Overview tab.
Since A2 the Add Plant sheet carries an Auto Generate Dockets tick box and the create body
carries auto_generate_dockets. Both paths run the SAME sequence — enableAutoGenCore
(backend/api/internal/plantregister/service.go) — so the invariant fork, the allocation hard gate,
the Temporal-first ordering and the compensating unregister exist once, not twice:
| Path | Entry | On refusal |
|---|---|---|
POST /{plantId}/auto-gen/enable | EnableAutoGen = load plant + core | 409 (allocation gate) or 400 (invariant) |
POST /plant-register with the tick box | Create → enableAtCreate → core, post-commit | 201 + auto_gen_blocked — never a failed create |
Why the create path answers 201 on refusal. The plant row is already committed when the enable sequence runs (registering a Temporal schedule for a row that might still roll back is the worse failure). Turning a blocked enable into a failed create would either discard a fully entered sheet or leave a committed row behind a 4xx the client reads as “nothing happened” — and re-arm the duplicate -create trap the single-transaction create exists to kill. So the plant is created, automation stays off, and the body explains why:
{
"id": "…",
"display_id": "PR-0042",
"auto_generate_dockets": false,
"auto_gen_blocked": {
"reason": "allocation_gate_failed", // | invalid_configuration | scheduler_unavailable | enable_failed
"message": "Auto-generate is off: some rate allocations still need an admin's confirmation.",
"missing_allocations": ["rate_line"],
"deep_link": "/admin/rates-and-cost-data/<link_project_id>",
},
}The sheet treats this as success-with-a-notice: it closes (the plant exists) and raises a persistent
toast with the deep-link CTA. message is always authored copy — never a wrapped internal error
(D-SEC-1).
The invariant that keeps the DB honest is unchanged: Temporal registration happens before the DB
write, and a failed DB write unregisters the schedule. auto_generate_dockets = true with no live
schedule remains unreachable on both paths.
Anti-patterns
- Do not use
client.ExecuteWorkflow+time.Sleepfor cron-shaped work. Schedules survive worker restarts; in-process sleeps do not. - Do not call
ScheduleClient().Createdirectly. You lose ID validation, timezone parsing, observability, and reconciliation. - Do not use random IDs. Deterministic IDs are load-bearing — Temporal dedupe, reconciliation, and log correlation all rely on them.
- Do not use
Updateto change the workflow type or task queue. Those fields are not updatable; callUnregisterthenRegister.