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
| Was | Now | Where it is described |
|---|---|---|
Area, its propose/approve flow, its grants and AREA_VISIBILITY_SCOPED / PERMIT_AREA_REQUIRED | Deleted 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 one | Data model §7 |
| Facility plans as a version chain, one active per area group, with a fallback route | A flat set of named places with immutable images; several may be active; deactivate, never delete. All twelve pre-existing plans were deactivated by the migration | Data model §4 |
planId/planX/planY and latitude/longitude on a permit | One pinId. The geo coordinate and its map-URL parser are gone | Data model §3 |
The overlap warning keyed on areaId | Keyed on pinId — date ranges and daily windows, naming the other permit, the colliding time and the pin | Data 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-refresh | Safety app guide |
| The contractor closes; the officer's close is a backstop | Safety closes, always with a reason; contractor and inspector request closure (a flag, not a status); closing auto-checks entrants out with 'system' provenance | Permit lifecycle |
Field actions on a CLOSED/EXPIRED permit were partly ungated | Every field action refuses anything but ACTIVE/FIRE_MONITOR (PERMIT_NOT_ACTIVE) | Permit lifecycle |
| The inspector's fixed-spine visit | An 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 available | Safety app guide |
EMERGENCY/INCIDENT notified officers only | Per-noteType routing: those two reach officers and the permit's owner; CORRECTIVE_ACTION reaches the owner | Data model §8 |
| The review screen's new urgent strip had no input for an overdue gas reading | It 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 role | 1:1 with the permit type; still gated only behind CERT_TYPE_REQUIRED | Data model §6 |
| A certificate needed only dates | Licence number or attachment, at least one (CERT_LICENCE_OR_ATTACHMENT_REQUIRED), plus a description | Contractor app guide |
| PPE: three different vocabularies, nothing declared by the contractor | One 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 action | Data model §8 |
Worker.role — who a person is | Deleted. A worker is a name; the role is chosen per permit (template list + free text) | Data model §6 |
| Contractors could not read inspector visits | They can, on their own permits — API only so far | Data model §8 |
| One long detail page in each app | Tabs, with urgent state pinned above them | both guides |
| Contractor menu: five entries | Permits (with a History view mode, pager, search, filters) and Personnel (Certificates, Workers); Getting started in the top bar | Contractor app guide |
| A visit needed a fresh scan, always | API 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 offline | A 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 log | Scoped 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 worker | Deny 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-signature | Deleted. RequestCloseModal.vue sends POST /close-request; safety approves or closes directly, always with a reason | Permit 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 + filter | Safety app guide |
| A visit needed a fresh scan, always | Both halves now: a history entry offers Start Visit when canStartFromHistory === true, source: 'history' recorded | Safety app guide |
| Safety's ten-item menu, inspector's six-item menu | Cut 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 nav | Safety 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 alone | Safety 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 fallback | Applications |
| The permit report / print view (112) | Both frontend halves: a Report tab in each app (visits, gaps, closure summary), print stylesheet, no PDF dependency | both guides |
| Contractor reads inspector visits (119's UI) | Built — the Report tab renders full visit content, notes included, on the contractor's own permits | Contractor 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
| Item | State 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 pin | Round 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 count | Never checked; CERT_TYPE_REQUIRED should not be switched on until it is |
What is live and proven
| Thing | State |
|---|---|
| VM stack | postgres, redis, minio, api, nginx, cloudflared up and healthy on the droplet |
https://api.e-safework.com | Answering. Reached through the tunnel; no inbound port on the box |
| CORS for all four origins | Verified live, see below |
| Contractor SPA | Deployed at https://esw-contractor.pages.dev |
| Safety/Inspector SPA | Deployed 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.devOne 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:
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/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: 5Env 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:
cd /opt/esw
IMAGE_TAG=<sha-currently-running> docker compose -f docker-compose.prod.yml up -d --wait apiRead the running SHA off docker inspect first — do not guess it.
Blockers
1. Login cannot work from *.pages.dev — cookie domain
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.compages.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 project | Custom domain |
|---|---|
esw-contractor | app.e-safework.com |
esw-safety | safety.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 dev → main
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 foundHarmless 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
renderedPlanId hardcoded to nullClosed. 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
EXPIRED is never assignedClosed. 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.