ModulesDocketsLabour Approval

Sign-On Docket Labour Approval

A structured time-negotiation loop sits between submitted and reviewed for sign-on dockets only (prestart_sign_on_id IS NOT NULL). Before a supervisor can mark a sign-on docket reviewed, the field worker (“labour”) and the supervisor must agree on the shift times — or the supervisor force-approves under a configured condition. Non-sign-on dockets are completely unaffected.

The docket’s own status (draft → submitted → reviewed → confirmed) is never extended. All negotiation sub-state lives in two append-only tables (docket_labour_negotiations, docket_labour_proposals).

Per-project rollout (default OFF)

The loop is gated per project and is off by default. Enable it by setting projects.metadata:

{
  "negotiation_config": {
    "enabled": true,
    "override_hours_threshold": 24,
    "override_rounds_threshold": 3
  }
}
  • enabled — the feature gate (absent/false ⇒ sign-on dockets submit and review exactly as before this feature shipped; no negotiation row is created).
  • override_hours_threshold — hours before “Force Approve” becomes eligible (default 24).
  • override_rounds_threshold — proposal rounds before “Force Approve” becomes eligible (default 3).

A project toggled off mid-negotiation still has its in-flight dockets blocked at review until the open negotiation settles — there is no bypass.

Actor roles

  • Supervisor — the docket’s primary_supervisor_id, or any user whose role passes canReviewDocket (admin / engineer). Seeds the initial times (from the submitted docket), responds to counter-proposals, approves, or force-approves.
  • Labour approver — resolved automatically at submit time:
    • single linked worker on the docket → that worker;
    • multiple workers → the one flagged is_leading_hand (earliest created_at if several are flagged, with a logged warning);
    • zero eligible (no leading hand on a crew docket, or every worker row is headcount-only with no linked user account) → submit is rejected with HTTP 422 no-leading-hand-on-crew-docket. Set a leading hand (PUT /workers/{id} is_leading_hand=true) or link the workers, then re-submit.

Associated supervisors can view the docket and the negotiation history, but cannot propose/approve/override (one actor per side keeps the loop turn-based).

State machine

docket_labour_negotiations.status:

awaiting_labour      — created on submit; labour acts first
awaiting_supervisor  — labour counter-proposed; supervisor must respond
approved             — either party accepted the other's latest proposal
overridden           — supervisor force-approved, or the Temporal timeout fired
cancelled            — docket was unsubmitted / soft-deleted; negotiation voided

A current_turn lock (labour / supervisor) is enforced atomically in every mutation’s SQL WHERE clause, so concurrent actors can never both act.

Transition table

FromActionToTurn
(none)submit sign-on docket (times set + leading hand resolved)awaiting_labourlabour
awaiting_labourlabour-approveapproved—
awaiting_labourlabour-counterawaiting_supervisorsupervisor
awaiting_laboursupervisor revises seedawaiting_labour (round++)labour
awaiting_supervisorsupervisor-approveapproved—
awaiting_supervisorsupervisor re-proposeawaiting_labour (round++)labour
awaiting_*override (force-approve, eligible)overridden—
awaiting_labourTemporal timeoutoverridden (system actor)—
any activeunsubmit / soft-deletecancelled—
approved/overriddenunreviewre-awaiting_labour (old row cancelled)labour
approved/overriddenreviewunchanged (docket → reviewed)—

The review gate

ReviewDocket (and both bulk-review paths) block a sign-on docket whose negotiation is still open (status NOT IN ('approved','overridden','cancelled')) with HTTP 409 labour-approval-required. Bulk review reports the blocked dockets per-id; bulk-review-by-filter silently skips them. Bulk submit initiates a negotiation for each affected sign-on docket exactly like the single-docket path, so a bulk-submitted docket is never left un-reviewable.

Override

“Force Approve” becomes eligible once either threshold is met: hours_elapsed ≥ override_hours_threshold or rounds_completed ≥ override_rounds_threshold. A human force-approve always records override_condition = 'manual' (the eligibility reason — timeout / rounds_exceeded — is preserved in the audit metadata.eligibility_reason).

A Temporal LabourApprovalTimeoutWorkflow sleeps for override_hours_threshold after the negotiation opens; if it is still open when the sleep elapses, ForceApproveAfterTimeout auto-approves with override_condition = 'timeout' and a system actor (actor_id = NULL, rendered as “System” in the audit timeline). Any counter-propose cancels and restarts the timeout.

Proposed times

A proposed start and finish is the clock the person typed, on the docket’s date. Accepting it puts exactly those times on the docket. See Dates & Times.

Reprice

When either party accepts (or an override fires) the agreed times are committed to the docket inside a transaction, then repriceDocket runs as a separate best-effort transaction. A reprice failure after a committed approval is logged and recoverable — touch the docket to re-trigger a reprice before review.

Edge cases

  • Missing shift times — a sign-on docket with NULL start/end cannot enter the loop: submit is rejected 422 missing-shift-times. Set the times first.
  • Unsubmit / soft-delete — cancels the negotiation and the Temporal timeout.
  • Unreview — voids the settled agreement and re-opens a fresh negotiation so any revised times are re-agreed before the next review.
  • Multi-supplier projects — labour actions additionally verify the docket’s supplier_org_id matches the caller’s org (or the caller is an HC admin). An HC-org-owned docket (no supplier org) authorises on the labour-approver identity alone.

Mobile field flow

  1. The supervisor submits the sign-on docket → an amber dot appears on the docket card in the field dockets list, signalling the field worker that their shift times need review.
  2. The worker opens the docket. The Labour Approval zone (between Shift Details and Workers) shows the proposed times; Shift Details overlays the proposed time in amber with the current value struck through.
  3. The worker taps Accept Times (confirm) or Counter-Edit (a bottom sheet to propose new times + a note).
  4. Both parties are kept in sync in real time via Postgres LISTEN/NOTIFY → WebSocket → TanStack Query invalidation.

Push notifications are deferred. The negotiation loop is fully functional without them — online clients update live via the WebSocket → TanStack Query invalidation path above. The server-side push-token endpoint and the SendNegotiationPush activity are committed but dormant; the mobile expo-notifications integration ships in a future native EAS build (it cannot ride an OTA update, as it requires a native module not in the current store binary).

API reference

All endpoints are under /v1/orgs/{orgId}/projects/{projectId}/dockets/{docketId}/labour-approval:

Method · PathOperationActor
GET /GetDocketLabourApprovalsupervisor or labour approver
POST /supervisor-proposeSupervisorProposeDocketTimessupervisor
POST /labour-approveLabourApproveDocketTimeslabour approver
POST /labour-counterLabourCounterProposeDocketTimeslabour approver
POST /supervisor-approveSupervisorApproveDocketTimessupervisor
POST /overrideOverrideDocketLabourApprovalsupervisor (eligible)

ProposeTimesRequest takes proposed_start_time and proposed_end_time as HH:MM, 24-hour. A date-time is refused with a 400.

Push-token registration: POST /v1/orgs/{orgId}/push-token (RegisterPushToken).

See the generated OpenAPI client (packages/api-client) for the exact request / response schemas (ProposeTimesRequest, NegotiationApproveRequest, NegotiationOverrideRequest, DocketLabourNegotiation, NegotiationProposal).