Skip to content

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. Worker is an entity, owned by the contractor account that registered them, and certificates, permit rows and entrant scans all carry a workerId. workerName exists 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.
  • certType gates 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 Testing was dropped in round 4. With CERT_TYPE_REQUIRED unset — 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

FromToCarried byNote
PermitFacility Plan and PinpinIdNullable. Required at submit only once an active pin on an active plan exists. The pin knows its own plan.
PermitCertificationworkerIdA real foreign key. A worker with no card reads as missing; a misspelling can no longer invent a second person.
Field VerificationPermitInspectorVisitReferences the permit; changes none of its fields.
Field VerificationPermitQR tokenResolves to live status, never a snapshot.
AnythingAuditactor + payloadThe payload is inside the hash, so what it records is tamper-evident for free.
PermitNotificationrole broadcastTargets 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.

TermMeansDoes not mean
PermitPermission to perform one dangerous job in one place during one time windowA document template
ForemanThe contractor who created the permit. Since round 4, no longer the one who closes itA job title in the system — there is no foreman role
WorkerA person registered by a contractor, with a QR card carrying their id. A name; the role is chosen per permitA user account. Workers never log in
Work windowA date range plus one daily start/end time that repeats across itA single day, or a separate window per day
VisitOne scan-started inspector run, worked from an action menu, with its own notes and photosThe inspector's view of the permit's whole life
EntrantA worker checked into a confined space right nowAnyone listed on the permit
Fire WatchThe mandatory 30-minute watch after hot work stopsThe person watching
Facility planA named place with an immutable image, owned by safetyA version of one site-wide drawing
PinA named position on a facility plan, placed by safety; the contractor selects oneA position the contractor drops themselves
AreaRemoved in round 4. The pin replaced it
ClosureThe safety officer's act, with a reason. A contractor or inspector requests it — a flag on the permit, not a statusThe foreman's checklist (it was, until round 4)
ExpiredThe work window ended while the permit was still openCancelled, or rejected
RejectedAn officer refused it, with a written reasonDeleted. It can be revised and resubmitted
ScanReading a QR at the place, nowOpening the permit on a screen

Rules that hold everywhere

These bind every context and are not re-litigated per feature:

  1. The server's verdict is authoritative. Clients localize off an errorCode; the backend's English message is never rendered to a user.
  2. The audit log is append-only and hash-chained. No exceptions, no admin path.
  3. Accounts are deactivated, never deleted.
  4. 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.
  5. 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.)
  6. Every string ships in English and Thai, default Thai, timestamps stored UTC and displayed in Asia/Bangkok.

Where to go next