Skip to content

Current state & blockers

The round-4 tables below were first checked against the code at api f8ccf31, contractor 9afd72ed and safety 6283b011 (all 2026-09-11), then re-checked 2026-09-12 (wayfinder 114) against every commit that landed since: api's 108-step-3 and 112 halves, contractor bd6ca96d/e2c50975/8265679f, safety 9529d490/488676ad/14d52ae1/65cb9060/f41eed90/dfe4e96e. Live-deployment facts below were verified against the running deployment on 2026-08-27 and have not been re-verified since. Blocker 10 was corrected on 2026-09-10, blocker 9 on 2026-09-11 and blocker 11 on 2026-09-12, because the code changed under them; treat every other row as "true as of 2026-08-27" and re-check before acting on it.

Where the current picture actually lives

./init.sh at the workspace root prints the deploy lag per repo, and it dates its own numbers — a run without --fetch prints a STALE READ banner before the figures. This page is a snapshot; that banner is the live reading. A stale unfetched run is how "~20 days undeployed" outlived the backlog by several days.

CR round 4 — what changed (approved and built 2026-09-11)

Round 4 was approved on 2026-09-11 after a nineteen-question grilling, and most of it was built the same day across all three repos. The rulings, with every reversal named against the ruling it reverses, are in PROMPT-LOG.md session 13; the tickets are on the round-4 map (docs/wayfinder/map-round-4-pins-closure-and-the-inspector-menu.md). The pages on this site were corrected in place on the same date — see Doc drift.

Why the reversals are safe to read as deliberate: twelve of round 4's items reverse an earlier ruling and five reverse work shipped on 2026-09-10. The one worth knowing: the inspector's action menu restores a freedom the fixed spine removed, and it is only safe because the gas clock moved server-side — the 2-hour sweep notifies and escalates regardless of what any visit records.

Shipped

WasNowWhere it is described
Area, its propose/approve flow, its grants and AREA_VISIBILITY_SCOPED / PERMIT_AREA_REQUIREDDeleted from all three repos. A named Pin, placed by safety on a named facility plan, replaces it. The contractor selects a pin rather than proposing an area or placing oneData model §7
Facility plans as a version chain, one active per area group, with a fallback routeA flat set of named places with immutable images; several may be active; deactivate, never delete. All twelve pre-existing plans were deactivated by the migrationData model §4
planId/planX/planY and latitude/longitude on a permitOne pinId. The geo coordinate and its map-URL parser are goneData model §3
The overlap warning keyed on areaIdKeyed on pinId — date ranges and daily windows, naming the other permit, the colliding time and the pinData model §7
The risk map drew "the" active plan (via a route that no longer existed)A plan switcher among active plans (?plan=), live permits on other plans listed by name, status colours and a legend, and a visible auto-refreshSafety app guide
The contractor closes; the officer's close is a backstopSafety closes, always with a reason; contractor and inspector request closure (a flag, not a status); closing auto-checks entrants out with 'system' provenancePermit lifecycle
Field actions on a CLOSED/EXPIRED permit were partly ungatedEvery field action refuses anything but ACTIVE/FIRE_MONITOR (PERMIT_NOT_ACTIVE)Permit lifecycle
The inspector's fixed-spine visitAn action menu — four items, five on Confined Space; only check-in/out blocks submit, and reviewing the table satisfies it; an entrant row can be marked not availableSafety app guide
EMERGENCY/INCIDENT notified officers onlyPer-noteType routing: those two reach officers and the permit's owner; CORRECTIVE_ACTION reaches the ownerData model §8
The review screen's new urgent strip had no input for an overdue gas readingIt shows one, from the server's own gasReadingStatus verdict (interval 120 min + 30 grace, unchanged)Safety app guide
certType included Gas Testing and was filtered by the worker's role1:1 with the permit type; still gated only behind CERT_TYPE_REQUIREDData model §6
A certificate needed only datesLicence number or attachment, at least one (CERT_LICENCE_OR_ATTACHMENT_REQUIRED), plus a descriptionContractor app guide
PPE: three different vocabularies, nothing declared by the contractorOne seven-item EPpeItem; the contractor declares (optional unless PPE_REQUIRED); the inspector checks what was declared and can flag an undeclared gap as a corrective actionData model §8
Worker.role — who a person isDeleted. A worker is a name; the role is chosen per permit (template list + free text)Data model §6
Contractors could not read inspector visitsThey can, on their own permits — API only so farData model §8
One long detail page in each appTabs, with urgent state pinned above themboth guides
Contractor menu: five entriesPermits (with a History view mode, pager, search, filters) and Personnel (Certificates, Workers); Getting started in the top barContractor app guide
A visit needed a fresh scan, alwaysAPI only: a visit may start from history whenever that inspector has ever scanned the permit (permit_scans, SCAN_WINDOW_EXPIRED). Corrected 2026-09-13 (on dev, not yet merged): originally capped at the earlier of 12 hours and the work-window end since the last scan — an owner decision removed that cap after it kept forcing re-scans on permits still ACTIVE.Data model §8
Only the stand-alone entrant and gas screens queued offlineA check-in/out inside a visit queues too; not-available, the PPE check and the visit submit cannot be replayed and say so; a rejected replay shows its reason (ticket 111)Safety app guide
Any contractor could read any permit's entrant register and gas logScoped to the permit's own contractor, like detail, audit and visits (ticket 112's API half)
A signed-in session with no role passed the contractor scope as if it were an officer — every permit, visit, entrant register, gas log, certificate and workerDeny by default: twelve scoped reads now name who may read (owner-scope.guard.ts); eleven routes left open deliberately, each commented why. Reachable before only if a role-less account existed, which needs USER_ENABLE_SIGNUP=TRUE (ticket 125)
Contractor's closure checklist + e-signatureDeleted. RequestCloseModal.vue sends POST /close-request; safety approves or closes directly, always with a reasonPermit lifecycle, both guides' Closing sections
Officer's close, worded as a "backstop"A normal action — Close permit / Approve close request; CloseRequestBanner.vue, a PermitCard chip, and an All-permits column + filterSafety app guide
A visit needed a fresh scan, alwaysBoth halves now: a history entry offers Start Visit when canStartFromHistory === true, source: 'history' recordedSafety app guide
Safety's ten-item menu, inspector's six-item menuCut to five and three (useNavItems.ts's NAV_ITEMS) — Users tabbed, Audit log into Dashboard, Getting started in the app bar; Entrant register and Gas log keep their routes, off the inspector navSafety app guide
Risk-map attention markers (108 step 3)Both halves: GET /permits's attention flags now render as a corner badge + legend count, never colour aloneSafety app guide
Socket service and live badges (109)All three halves: GET /api/v1/realtime (api), useRealtimeSocket.ts (contractor), useSocket.ts rewired from vestigial to live (safety); /v1/badges / /notifications polling kept only as fallbackApplications
The permit report / print view (112)Both frontend halves: a Report tab in each app (visits, gaps, closure summary), print stylesheet, no PDF dependencyboth guides
Contractor reads inspector visits (119's UI)Built — the Report tab renders full visit content, notes included, on the contractor's own permitsContractor app guide
Telling inspectors their notes are contractor-visible (112)Built — the notice ships on the inspector's getting-started page and beside the note field on InspectorVisitPage

Not shipped yet — do not read the rows above as covering these

ItemState on 2026-09-12
Offline replays keep the time they happened (126)Not built — a queued check-in/out or gas reading is recorded at sync time
Production has no active facility plan or pinRound 4's migration deactivated all twelve pre-existing plans and dropped every old permit position; naming a plan and placing pins is an owner-scheduled action, not a code gap — see Open items
Production Gas Testing certificate countNever checked; CERT_TYPE_REQUIRED should not be switched on until it is

What is live and proven

ThingState
VM stackpostgres, redis, minio, api, nginx, cloudflared up and healthy on the droplet
https://api.e-safework.comAnswering. Reached through the tunnel; no inbound port on the box
CORS for all four originsVerified live, see below
Contractor SPADeployed at https://esw-contractor.pages.dev
Safety/Inspector SPADeployed at https://esw-safety.pages.dev

CORS — fixed and verified

CORS_ORIGIN in /opt/esw/.env now reads:

CORS_ORIGIN=https://app.e-safework.com,https://safety.e-safework.com,https://esw-safety.pages.dev,https://esw-contractor.pages.dev

One variable, two consumers — @elysiajs/cors and better-auth's trustedOrigins. No source change was needed; src/libs/utils/cors.util.ts reads it at boot.

The preflight was checked against production rather than assumed:

bash
curl -X OPTIONS https://api.e-safework.com/api/v1/permits \
  -H 'Origin: https://esw-safety.pages.dev' \
  -H 'Access-Control-Request-Method: GET' \
  -H 'Access-Control-Request-Headers: content-type'
http
HTTP/2 204
access-control-allow-credentials: true
access-control-allow-headers: content-type
access-control-allow-methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
access-control-allow-origin: https://esw-safety.pages.dev
access-control-max-age: 5

Env changes need a recreate, not a restart

env_file is read when the container is created. docker compose restart api re-runs the old environment. Recreate instead, pinning the tag already running so you do not accidentally roll forward:

bash
cd /opt/esw
IMAGE_TAG=<sha-currently-running> docker compose -f docker-compose.prod.yml up -d --wait api

Read the running SHA off docker inspect first — do not guess it.

Blockers

This is the next wall, and it is not a CORS problem.

COOKIE_DOMAIN=.e-safework.com   →   Set-Cookie: __Secure-better-auth.session_token=…; Domain=.e-safework.com

pages.dev is a different registrable domain. The browser rejects that cookie outright and never sends it back. The login POST returns 200, the session is empty, and every following request is 401.

Fix: attach the custom domains in each Pages project's Custom domains tab —

Pages projectCustom domain
esw-contractorapp.e-safework.com
esw-safetysafety.e-safework.com

Both are already in CORS_ORIGIN, so nothing else changes. The *.pages.dev entries stay harmless and remain useful for preview deploys of anything that does not need a session.

The alternative — unsetting COOKIE_DOMAIN so the cookie goes host-only on api.e-safework.com — forces SameSite=None, i.e. a third-party cookie. Safari blocks it, Chrome is phasing it out. Not worth doing.

2. Nothing deploys until devmain

All four workflows trigger on push: [main]. Until dev is merged into main in all three repos, no pipeline runs. Then: set main as the GitHub default branch, and add a ruleset requiring a PR plus the check status check.

Do not read the current lag off this page. Run node scripts/check-deploy-lag.mjs --fetch from the workspace root — without --fetch the numbers can be days old, and the script says so per row.

3. Cloudflare Pages production branch

Pages keeps its own production-branch setting per project, independent of the workflow. Set both esw-contractor and esw-safety to main.

4. GMAIL_PASS breaks any shell that sources .env

/opt/esw/.env line 62 holds a Gmail app password containing spaces, unquoted:

./.env: line 62: ajas: command not found

Harmless to Docker Compose — env_file is parsed literally, not by a shell — but it breaks set -a; . ./.env, including backup.sh. Quote the value.

5. Backups are not running

No crontab entry for backup.sh, and no DigitalOcean weekly droplet backups enabled. The script is copied to the VM by every deploy and chmod +x'd, but nothing invokes it. See blocker 4 — it will not work until GMAIL_PASS is quoted.

6. SYSTEM_ADMIN_PASSWORD has been used and still sits in .env

Rotate it.

7. MANAGEMENT_URL is single-valued

Password-reset emails link to it, and it points at safety.e-safework.com. Contractors therefore receive a reset link into an app they cannot log into. This is a product decision, not a bug — either route by permitRole when composing the email, or accept it.

8. Frontend CI is unverified

Neither frontend pipeline has been run green yet.

9. SFO-007 risk map — renderedPlanId hardcoded to null — fixed, then superseded

Closed. The original defect is long gone, and so is the model it lived in: round 4 abolished plan versions and area drawings, so "a pin on a version other than the one being drawn" no longer exists. What replaced it (tickets 123, 124, 108 step 1, 2026-09-11): the risk map draws one of the active plans, chosen with a switcher and kept in ?plan=; every live permit that cannot be drawn on it is listed by name beneath the map — on another active plan, on a deactivated pin or plan, or not placed at all. The completeness rule from wayfinder 091 still holds: no live permit is absent from the screen without being named on it. Found on the way (124): the map was still calling the deletedGET /facility-plans/active and reading the 404 as "no plan", so it would have drawn nothing the moment safety activated its first plan. (Earlier, 094 found it fetching one page of 100 permits against a register of 531.)

10. EXPIRED is never assigned — fixed

Closed. An in-process cron (PermitExpiryCronPlugin) now sweeps PENDING and ACTIVE permits past their work-window end into EXPIRED every minute, writing a PERMIT_EXPIRED audit row with a null actor. An ACTIVE Hot Work permit gets the Fire Watch duration as grace first, and the sweep never starts a Fire Watch. See Permit lifecycle.

The same tick now also runs the gas-reading overdue sweep. Both consequences are the same one: a second api replica would double every sweep, on top of the migration race already documented.

11. The contractor app cannot close a permit, and has no way to ask — fixed

Closed 2026-09-12. Round 4 made closure safety_officer-only (ticket 098, API b74c799), and the field break this blocker described — reported by 114's first docs pass, which is why ticket 098 was reopened — is now closed on both frontends. The contractor app's checklist and its e-signature are gone, replaced by Request Closure → (RequestCloseModal.vue, contractor bd6ca96d), sending POST /close-request. The safety app's half also shipped (9529d490): the officer's close is a normal action, no longer worded as a backstop, and CloseRequestBanner.vue / a PermitCard chip / the All-permits filter surface GET /permits?closeRequested=true instead of leaving it unused. See Permit lifecycle.