Domain context
What this system is about, in the language the facility uses. The data model describes the tables; this page describes the meaning behind them — which parts of the domain own which rules, and what may never be true.
Everything here describes behaviour that exists today on the working branch. Where a rule is built but switched off by default, it is marked flag-gated and the flag is named. Nothing on this page is aspirational.
The one-sentence domain
A contractor asks permission to do dangerous work in a facility; a safety officer decides whether it is safe enough to allow; an inspector verifies on the plant floor that what was promised is what is happening. A permit is that permission, and the audit log is the tamper-evident record that it was granted honestly.
Bounded contexts
Each context owns one invariant that nothing outside it may violate. That ownership is the point of the boundary — if two contexts could both decide a rule, neither really owns it.
Permit — owns the status machine
The aggregate root. A permit carries its JSA rows, its workers and their PPE, its photo evidence, its safety readings and its closure checklist; none of those exist independently of it.
The invariant: status changes only along the machine, and only the server decides. A client may mirror a rule to give instant feedback, never to gate beyond it. See permit lifecycle for the transitions.
Closure is the safety officer's since round 4 (2026-09-11), always with a reason. A contractor (on their own permit) or an inspector requests closure, which sets a flag and changes no status. Closing a Confined Space permit with people still checked in succeeds and checks them out automatically, marked as done by the system. On screen this is only half there: the contractor app has no request button yet and its old closure checklist is refused by the server, and the safety app still presents its close as a backstop, with no request queue.
Certification — owns expiry
A certificate says a named person was assessed competent until a date. The invariant: an expired or missing certificate blocks the work, at submission and again at the gate, with no override in the field.
Three things about this context are worth reading carefully, because all three were different until recently:
- A worker is a record, not a name — but still not an account.
Workeris an entity, owned by the contractor account that registered them, and certificates, permit rows and entrant scans all carry aworkerId.workerNameexists on no table. Workers still never log in; their identity travels on a printed QR card that encodes the id, because an offline scanner has nothing to resolve a name against. Names are unique per contractor, case- and whitespace-insensitively, so "Somchai" and "somchai " are one person and one contractor's certificate can no longer satisfy another's gate. certTypegates only when a deployment turns it on. flag-gated The certificate types are exactly one per permit type — Hot Work, Confined Space Entry, Working at Heights;Gas Testingwas dropped in round 4. WithCERT_TYPE_REQUIREDunset — the default — any unexpired certificate satisfies any permit type. With it set, the permit's type requires its matching certificate type at both the submit check and the entrant scan. The vocabulary is compiled into the code, not an admin table.- A certificate carries a licence number, an attachment, or both — at least one of the two.
A worker is a name; the role belongs to the job. A worker no longer carries a role of their own (round 4 deleted it). What someone does is chosen per permit — the role on that permit, from a template list filtered by permit type or typed freely. The old role→certificate-type map went with it: the permit type alone decides which certificate is required.
Facility Plan and Pin — owns the shared set of places
A facility plan is a named place ("Floor 1", "Tank Farm") with an immutable image; a new scan is a new plan and the old one is deactivated — never edited, never deleted, and several plans may be active at once. A pin is a named position on a plan, placed by a safety officer: its name can be edited, its position is frozen (moving a pin means placing a new one), and it is deactivated, never deleted. The contractor selects a pin; nobody proposes places any more. The invariant: a place exists only because safety placed and named it — one pin means one place to everyone, which is what makes "two crews on the same pin" a question anyone can answer. Every contractor sees every pin.
This replaces the Work Area, removed in round 4 (2026-09-11) together with its propose/approve flow, its per-contractor grants and both of its switches (AREA_VISIBILITY_SCOPED, PERMIT_AREA_REQUIRED). A permit now carries only pinId; its old plan position (planId/planX/planY) and its geo coordinate are gone. A pin becomes required to submit only once an active pin on an active plan exists — three deliberate acts by safety, never a side effect of a migration.
Field Verification — owns proof of presence
The inspector's context: scan a permit's QR to see its live status, check entrants in and out, log gas readings. The invariant: a scan proves presence at a moment, and nothing else does — so reading a permit from history grants no authority to act on it. Round 4 stretches "a moment" for one action only: the server lets an inspector start a visit from history while their own last scan of that permit is recent — within the earlier of 12 hours and the end of the work window, recorded in an append-only scan log. The scan still proves presence; it now proves it for the day. The app does not offer this yet.
A scan starts a visit — one append-only record of one trip to the permit, carrying that trip's PPE checklist, notes and photos. A visit is one site visit, not the permit's lifetime, and the permit shows a timeline of visits. Since round 4 the inspector works a visit from an action menu rather than a fixed spine: check-in/check-out (the only item required to submit, satisfied by reviewing the entrant table rather than changing it), PPE check, a gas reading on Confined Space only, a note, and a closure request. That freedom is safe only because the gas clock is server-owned: a reading is due every 120 minutes with a 30-minute grace, and the server's sweep notifies and escalates whatever any visit records. A worker who is not on site is marked not available on their permit row — an absence, never an entrant event.
The inspector checks the PPE the contractor declared, and can flag an undeclared item as a gap, which must be carried by a corrective-action, emergency or incident note. A note is typed: GENERAL, WARNING, CORRECTIVE_ACTION, EMERGENCY or INCIDENT. EMERGENCY and INCIDENT notify the safety officers and the permit's owner; CORRECTIVE_ACTION notifies the owner only; GENERAL and WARNING notify nobody and are read in the timeline. The owning contractor may read the full visit record of their own permits through the API — the contractor app does not display visits yet.
The second invariant of this context: a visit changes no permit field. The inspector witnesses and may request closure; the safety officer closes. One role does not get both "I saw it" and "I ended it" — that separation is what the audit chain exists to preserve. Inspector-triggered suspension was considered and ruled out: a real emergency is a radio call, and the feature would let an inspector halt a crew by mis-tapping. A CLOSED or EXPIRED permit cannot be inspected at all: every field action refuses anything but ACTIVE/FIRE_MONITOR, while the QR lookup still answers, because its job is to say the permit is closed.
This is the only context that must work with no network. Actions queue on the device and sync when the connection returns, with conflicts surfaced rather than silently resolved. That queue exists; a precached offline app shell does not yet, so treat working-with-no-signal as the requirement being built toward rather than a shipped guarantee.
Audit — owns the hash chain
One facility-wide, append-only chain. Every state-changing action links to the hash of the row before it, so removing or altering any row breaks verification from that point on. The invariant: no edit path and no delete path exists, anywhere, for anyone.
This is why accounts are deactivated rather than deleted: audit rows name their actor, and deleting the account would orphan the history of every permit that person touched.
Identity and Access — owns role and deactivation
Exactly one role per account, set by a safety officer; public signup is disabled. The app shows what the role allows — it is never a toggle the user picks. The invariant: an account is deactivated, never deleted.
What crosses a boundary, and how
| From | To | Carried by | Note |
|---|---|---|---|
| Permit | Facility Plan and Pin | pinId | Nullable. Required at submit only once an active pin on an active plan exists. The pin knows its own plan. |
| Permit | Certification | workerId | A real foreign key. A worker with no card reads as missing; a misspelling can no longer invent a second person. |
| Field Verification | Permit | InspectorVisit | References the permit; changes none of its fields. |
| Field Verification | Permit | QR token | Resolves to live status, never a snapshot. |
| Anything | Audit | actor + payload | The payload is inside the hash, so what it records is tamper-evident for free. |
| Permit | Notification | role broadcast | Targets a role, not a person, unless the notice is personal. |
Ubiquitous language
Terms that mean something specific here. Using them loosely is how two people end up building different systems.
| Term | Means | Does not mean |
|---|---|---|
| Permit | Permission to perform one dangerous job in one place during one time window | A document template |
| Foreman | The contractor who created the permit. Since round 4, no longer the one who closes it | A job title in the system — there is no foreman role |
| Worker | A person registered by a contractor, with a QR card carrying their id. A name; the role is chosen per permit | A user account. Workers never log in |
| Work window | A date range plus one daily start/end time that repeats across it | A single day, or a separate window per day |
| Visit | One scan-started inspector run, worked from an action menu, with its own notes and photos | The inspector's view of the permit's whole life |
| Entrant | A worker checked into a confined space right now | Anyone listed on the permit |
| Fire Watch | The mandatory 30-minute watch after hot work stops | The person watching |
| Facility plan | A named place with an immutable image, owned by safety | A version of one site-wide drawing |
| Pin | A named position on a facility plan, placed by safety; the contractor selects one | A position the contractor drops themselves |
| Removed in round 4. The pin replaced it | — | |
| Closure | The safety officer's act, with a reason. A contractor or inspector requests it — a flag on the permit, not a status | The foreman's checklist (it was, until round 4) |
| Expired | The work window ended while the permit was still open | Cancelled, or rejected |
| Rejected | An officer refused it, with a written reason | Deleted. It can be revised and resubmitted |
| Scan | Reading a QR at the place, now | Opening the permit on a screen |
Rules that hold everywhere
These bind every context and are not re-litigated per feature:
- The server's verdict is authoritative. Clients localize off an
errorCode; the backend's Englishmessageis never rendered to a user. - The audit log is append-only and hash-chained. No exceptions, no admin path.
- Accounts are deactivated, never deleted.
- Safety thresholds are re-validated server-side on submit and approve, never trusted from the client. LEL
0%, O₂19.5–23.5%, CO≤50 ppm, wind≤25 km/h, per permit type. - No third-party runtime request, no CDN, in either app — which is also why a map picker was declined: tiles are a third-party fetch on every pan. (The pasted-map-URL coordinate that stood in for it was removed in round 4; a permit's place is its pin.)
- Every string ships in English and Thai, default Thai, timestamps stored UTC and displayed in
Asia/Bangkok.
Where to go next
- What each role has to do — the three journeys and how they hand off.
- Why this system exists — goal, objectives and how success is judged.
- Permit lifecycle — the status machine in detail.
- Data model — the tables behind all of this.