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 passescanReviewDocket(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(earliestcreated_atif 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 voidedA current_turn lock (labour / supervisor) is enforced atomically in every
mutation’s SQL WHERE clause, so concurrent actors can never both act.
Transition table
| From | Action | To | Turn |
|---|---|---|---|
| (none) | submit sign-on docket (times set + leading hand resolved) | awaiting_labour | labour |
| awaiting_labour | labour-approve | approved | — |
| awaiting_labour | labour-counter | awaiting_supervisor | supervisor |
| awaiting_labour | supervisor revises seed | awaiting_labour (round++) | labour |
| awaiting_supervisor | supervisor-approve | approved | — |
| awaiting_supervisor | supervisor re-propose | awaiting_labour (round++) | labour |
| awaiting_* | override (force-approve, eligible) | overridden | — |
| awaiting_labour | Temporal timeout | overridden (system actor) | — |
| any active | unsubmit / soft-delete | cancelled | — |
| approved/overridden | unreview | re-awaiting_labour (old row cancelled) | labour |
| approved/overridden | review | unchanged (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_idmatches 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
- 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.
- 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.
- The worker taps Accept Times (confirm) or Counter-Edit (a bottom sheet to propose new times + a note).
- 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
SendNegotiationPushactivity are committed but dormant; the mobileexpo-notificationsintegration 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 · Path | Operation | Actor |
|---|---|---|
GET / | GetDocketLabourApproval | supervisor or labour approver |
POST /supervisor-propose | SupervisorProposeDocketTimes | supervisor |
POST /labour-approve | LabourApproveDocketTimes | labour approver |
POST /labour-counter | LabourCounterProposeDocketTimes | labour approver |
POST /supervisor-approve | SupervisorApproveDocketTimes | supervisor |
POST /override | OverrideDocketLabourApproval | supervisor (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).