Skip to content

Permit lifecycle

Round 4 (2026-09-11) changed closure — both apps caught up by 2026-09-12

Closing a permit is now the safety officer's act, not the foreman's — reversing ticket 020 and round 3's own answer the day before. A contractor or an inspector requests closure; that request is a flag on the permit, not a new status, so the diagram below has no "close requested" node. See Closure is the safety officer's. Both frontends now build and present this correctly — the contractor requests, safety approves or closes directly, with a visible request signal in both apps.

Status machine

Each transition is written by exactly one command service under src/modules/permit/commands/:

TransitionCommandGuard
DRAFTcreatecontractor
PENDINGsubmitcontractor, own permit, only from DRAFT or REJECTED, else 403 PERMIT_NOT_SUBMITTABLE. Runs the position gate, the PPE gate (flag-gated) and a per-worker certificate check first — see Submit gates
ACTIVEapprovesafety_officer
REJECTEDrejectsafety_officer, rejectedReason required
FIRE_MONITORmark-completecontractor, own permit, Hot Work only, from ACTIVE. Stamps fireMonitorStartedAt and starts the mandatory 30-minute Fire Watch countdown
CLOSEDclosesafety_officer only, reason always required (403 CLOSURE_REASON_REQUIRED). Hot Work can reach closure only through FIRE_MONITOR and only once the watch has elapsed (403 PERMIT_NOT_CLOSABLE / FIRE_WATCH_NOT_ELAPSED, no override)
EXPIREDexpire (cron)Never a human action — see "Expiry" below
(no transition)close-requestcontractor (own permit) or inspector, on an ACTIVE/FIRE_MONITOR permit. Sets a flag; moves nothing — see below

Closure is the safety officer's

What changed on 2026-09-11 (ticket 098). Until then closure was the foreman's act: a contractor closed their own permit with a checklist and signature, and a safety officer's close was built as a deliberately understated backstop for when the contractor could not. Round 4 reverses that. The officer now owns closure outright; everyone else asks.

Requesting closure — a flag, not a status

POST /permits/:id/close-request (contractor on their own permit, or inspector on any), on an ACTIVE or FIRE_MONITOR permit, optional reason. It sets closeRequestedAt / ById / By / Role / Reason, writes a PERMIT_CLOSE_REQUESTED audit row and broadcasts a notification to safety officers. The status does not move. Raising it again just refreshes who, when and why — it is a signal, not a lock.

Why not a CLOSE_REQUESTED status: a hot permit can be mid-Fire-Watch when the request arrives, and a dedicated status would have to steal the FIRE_MONITOR slot — breaking the countdown and the FIRE_WATCH_NOT_ELAPSED gate — or need one requested-close status per underlying state. As a flag on the same status column, every status-keyed query (dashboard counts, pin occupancy, the expiry and gas sweeps) needed no change, and a requested permit whose window lapses still expires normally. The cost: the queue is not a status filter. It is GET /permits?closeRequested=true.

The request fields are never cleared — not when the permit closes, not when it expires. A client that shows "closure requested" must also check the status is still ACTIVE/FIRE_MONITOR.

Closing — confirm a request, or close directly

Both paths are the same POST /permits/:id/close, safety_officer only, carrying the closure checklist, a signature and — now unconditionally — a reason. The PERMIT_CLOSED audit row records closeRequested: true|false, so "safety confirmed a request" and "safety closed with no request" stay distinguishable forever.

Closing with entrants still inside is now allowed. On a Confined Space permit, every worker still checked in is auto-checked-out at the closure timestamp, in the same transaction, each with an ENTRANT_CHECKED_OUT audit row carrying source: 'system' — a third provenance value beside scan and manual, written only here. A closed permit whose register still shows people inside would be a record that lies in the one place it must not. ENTRANTS_STILL_INSIDE is therefore no longer emitted; it stays declared in both apps, dormant.

What the apps do today

ServerApp
Contractor requests closureallowed on own permitbuilt (wayfinder 098's contractor half, bd6ca96d) — Request Closure → opens RequestCloseModal.vue, an optional reason to POST /close-request; a second request becomes Update Request, prefilled. The old checklist and its e-signature are deleted; PermitProvider.close() no longer exists in this app
Inspector requests closureallowedbuiltRequest Close in the visit menu
Officer sees the request queue?closeRequested=truebuilt (wayfinder 098's safety half, 9529d490) — CloseRequestBanner.vue above the tabs, a "Close requested" chip on PermitCard, and a column + server-side filter on All permits
Officer closesthe only way a permit closesbuilt, and now presented as a normal action — plain Close permit, or Approve close request prefilled with the request's reason

Hot Work's Mark complete is unaffected: it is still the contractor's, and still what starts the Fire Watch.

Submit gates

Checked in this order. The first two are each their own refusal, so the contractor gets one unambiguous reason; the last two are checked together and every failure is listed at once.

  1. Position400 PERMIT_POSITION_REQUIRED when the permit has no pinId and an active pin on an active plan exists. Until safety has created a plan, activated it and placed a pin on it, this gate is off for everyone.
  2. PPE flag-gated400 PPE_REQUIRED when PPE_REQUIRED is set and the permit declares no PPE. Unset (the default), PPE is optional.
  3. Safety readings — every blocking threshold revalidated server-side.
  4. Certificates — each worker must hold an unexpired certificate (CERT_MISSING / CERT_EXPIRED). flag-gated With CERT_TYPE_REQUIRED set it must also be of the permit's type.

The work window

A permit covers a date range with one daily window that repeats across it:

FieldTypeMeans
startDate / endDateDATEthe first and last calendar day the permit covers
dailyStart / dailyEndTIMEthe clock times work may run between, every day in that range
scheduleNotefree textwhat the window cannot express ("not working Sat/Sun"). Nothing queries it

workDate, workTimeStart and workTimeEnd no longer exist — not on the wire, not in the database. dailyStart/dailyEnd are Postgres TIME columns with no date part, which Prisma reads back anchored to 1970-01-01T00:00:00Z; render them through a local-time conversion, never as a UTC wall clock.

The single instant a permit's window closes is workWindowEndInstant()endDate's calendar day at dailyEnd's clock time. It is neither endDate alone (which would expire a multi-day permit at midnight on its last day) nor dailyEnd alone (which has no date). Expiry and the Fire Watch grace both measure against it.

Editability

update (PATCH) refuses the whole request in ACTIVE, FIRE_MONITOR, CLOSED and EXPIRED (403 PERMIT_NOT_EDITABLE), before any field is inspected.

DRAFT and REJECTED are the contractor's plain editable window. PENDING is also accepted, but only as a withdrawal: the same transaction that writes the fields drops the permit back to DRAFT, writes an audit row and re-notifies the officers who were told about the original submission. It is never an in-place edit that leaves the permit PENDING — an officer must never be able to approve a version they did not read — and the contractor resubmits.

A PATCH carrying no editable field at all is refused with 400 PERMIT_UPDATE_EMPTY before that withdrawal happens. The contractor app calls PATCH {} on mount purely as an editability probe; without this guard, merely opening the edit page withdrew a PENDING permit from review.

So the pin (pinId) is selectable exactly when the permit is DRAFT or REJECTED — unchanged as a rule, but "frozen from PENDING onward" is no longer a one-way description, because a permit can leave PENDING backwards and become editable again. (Before round 4 the same rule applied to planId/planX/planY, which no longer exist.)

What ACTIVE and FIRE_MONITOR unlock — and nothing else does

Since round 4 this is enforced server-side on every field action, not by hiding a button. Anything but ACTIVE/FIRE_MONITOR is refused with 403 PERMIT_NOT_ACTIVE — so a CLOSED or EXPIRED permit is read-only for inspection, always. Before 098, starting an inspector visit and logging a gas reading had no status gate at all.

  • QR tokenGET /permits/:id/qr returns a signed token for ACTIVE or FIRE_MONITOR only. The frontend renders the QR client-side.
  • Entrant scan — check-in/out. The scan reads a workerId off the worker's QR card (or the inspector picks the worker), re-checks that worker's certificate and, on a lapse, denies entry with 403 CERT_EXPIRED / CERT_MISSING and writes a CERT_BLOCKED audit entry.
  • Marking a worker not availablePOST /permits/:id/entrants/not-available (inspector), with a required note. Recorded on the permit's worker row, not as an entrant event; a later check-in clears it.
  • Gas logPOST /permits/:id/gas-log (inspector).
  • Inspector visitsPOST /permits/:id/inspector-visits (inspector) starts one append-only record per site visit, finalized once by its /submit. A visit changes no permit field and moves the permit through no transition. Started from history rather than a fresh scan, it is refused with 403 SCAN_WINDOW_EXPIRED only when that inspector has no recorded scan of the permit at all — ticket 101's original 12-hour/work-window time bound was reversed by an owner decision on 2026-09-13 (on dev, not yet merged); see Data model §8.
  • Closure request — see above.
  • The gas-reading clockgasReadingStatus is non-null only for a Confined Space permit in these two states.

GET /qr/:token — the public scan lookup — is the one deliberate exception: it answers for any status, because its job is to report live status, including "this permit is closed".

Status grouping in the UI

GET /permits?status= accepts either repeated params (?status=ACTIVE&status=FIRE_MONITOR) or a comma-joined list (?status=ACTIVE,FIRE_MONITOR). The contractor app's Permits view groups its filter chips like this; its History view mode covers CLOSED and EXPIRED. The safety app's All permits screen filters one status at a time.

ChipStatuses
Allevery status
ActiveACTIVE + FIRE_MONITOR
PendingPENDING
ClosedCLOSED + REJECTED

Expiry

EXPIRED is written by an in-process cron (PermitExpiryCronPlugin) that runs every minute and is mounted only in the real listen entrypoint, never in the module graph the specs import. It sweeps PENDING and ACTIVE permits past workWindowEndInstant() into EXPIRED, writing one append-only PERMIT_EXPIRED audit row each with a null actor — no human triggered it.

Three rules hold it in shape:

  • An ACTIVE Hot Work permit gets the Fire Watch duration as grace past its window end before it expires. A Fire Watch is by definition the period after hot work stops, so a hot permit's safety obligation always outlives its work window.
  • Expiry never starts a Fire Watch. A Fire Watch is a person standing there, and the server must never write a safety control no human performed. Instead, the moment a hot ACTIVE permit first passes its window end with no watch started, one alert fires — guarded once-only by fireWatchAlertedAt, because at one tick a minute an unguarded alert would send ~30 notifications per permit and get the channel muted.
  • A permit already in FIRE_MONITOR never expires, and the sweep is idempotent: each candidate is moved by an updateMany that re-asserts the status it was read with, so running it twice, or once after a long outage, only ever moves a permit once.

A pending closure request does not stop expiry — the flag rides on ACTIVE, and an ACTIVE permit past its window expires as always.

The gas-reading sweep shares this tick — one scheduler, one over-run guard — but nothing else; each is wrapped in its own try/catch so a fault in one cannot silence the other.

Offline sync

POST /api/v1/sync/batch (inspector-only) replays queued writes recorded while an inspector was offline. Idempotency comes from the client-generated offlineClientId UUID carried by entrant_events and gas_log_entries, both of which hold a unique index on it — a re-sent batch collides rather than duplicating. Per-item failures come back with the machine-readable domain error code (CERT_EXPIRED, PERMIT_NOT_ACTIVE, …) rather than failing the whole batch — and since round 4 an entry queued against a permit that closed or expired in the meantime comes back PERMIT_NOT_ACTIVE.

The endpoint is real; the offline shell is not

The route exists and the Safety/Inspector app has an OfflineQueue Pinia store over IndexedDB that uses it. It replays exactly two kinds of item, entrant_scan and gas_log: check-in/out from the entrant-register screen or from a visit's worker table (ticket 111), and gas readings from the gas-log screen. Marking a worker not available, the PPE checklist and the visit submit have no shape the batch route can replay, so the app never queues them and says so. A replayed item is currently stamped with the sync time, not the time it happened (ticket 126, open). What does not exist at all is a service worker: vite-plugin-pwa is installed in neither frontend, so a cold load with no network still gets nothing. Treat "the inspector works offline" as a requirement being built toward, not as a shipped guarantee.