← Guide

Configuring permits

Configuring the permit programme — permit types, authorised-issuer appointments, site policy (coverage and close-out gate), and the XLSX import.

For Site admin / configurator

Your role

You are the full-privilege site administrator (SiteMembership role admin, rank 3). You inherit both review bands plus all configuration rights — permit_request, permit_approve_stage1, permit_approve_stage2, and permit_settings_manage — so you can drive a permit through every desk gate (endorse, then approve), reject or request corrections at any gate, close out live/expired permits, run the legacy XLSX import (you hold contractor_manage), and own the entire permit programme: the permit-type catalogue, authorised-issuer appointments, and the two site policies. The two things you can't do by virtue of your role alone are the two field signatures: issuing a permit at the walk-down needs an active PermitAppointment (issuing keys on the appointment, never on your role — even admin), and countersigning needs you to actually be the permit's named receiver. Everything else on the site is yours.

What you can and can't do

You can
  • Raise permits yourself (permit_request) — create, edit, submit, withdraw your own drafts.
  • Endorse at stage 1 (permit_approve_stage1) — the coordination check, requestedendorsed.
  • Approve at stage 2 (permit_approve_stage2) — the H&S desk review, endorsedapproved (or requested → approved directly for single_stage types).
  • Reject or request corrections at whichever gate is currently acting.
  • Close out issued/expired permits (→ closed) via the inherited coordinator-band path.
  • Decide extension requests (approve/reject), moving a live permit's end_at in place.
  • Own all configuration: permit types, authorised-issuer appointments, and SitePermitConfig (coverage policy, close-out gate, nominee/deputy, expiring-warning lead).
  • Run the legacy XLSX permit import — upload, delete sets, re-resolve, reconcile (contractor_manage).
  • See every permit on the site (you are in the review band — cross-contractor, including the in-review middle), except other users' private drafts.
You can't
  • Issue at the walk-down without an active PermitAppointment — issuing is appointment-gated, not role-gated. Being admin (or even superadmin) is no shortcut. You appoint issuers; you are only an issuer if someone appointed you.
  • Countersign unless you are the permit's named receiver (permit.receiver_id) — countersign is identity-gated.
  • Edit/submit/withdraw another user's draft — requestor-ownership (_assert_owner) still applies; only a superadmin short-circuits it. You can see in-review permits across contractors, but you can't author someone else's draft.

Your tasks

Task 1 — Act at the endorsement (stage-1) gate

  1. A submitted permit raises an endorse_permit Task to the stage-1 band. Open My Work’s Tasks tab and look for the "Review & endorse" card (it deep-links into the review surface), or open the Permits panel directly (dock key P) and pick a permit sitting in the "In review" section.
  2. The review workbench opens: a read-only application pane, the live map lens behind the panel (linked entities glow, everything else dims), and a ranked Conflicts list (red-first).
  3. Optionally type free-text controls into "Added controls" (these become stage1_controls, stamped "Added at endorsement") and a note.
  4. Press Endorse. The system calls POST /api/permits/applications/{id}/endorse, records the action, and moves the permit requested → endorsed. The endorse_permit Task auto-resolves and the permit now awaits stage-2 approval.
  5. To bounce instead: Request corrections (→ corrections_requested, note required, returns it to the requestor editable) or Reject (→ rejected, terminal, reason required).

Note: single_stage permit types skip endorsement entirely — there is no stage-1 action on them. They go straight from requested to the stage-2 approve gate, so you'll only ever see an approve_permit Task for them, never an endorse one.

Task 2 — Act at the approval (stage-2) gate

  1. Open the "Review & approve" card in My Work, or open the review workbench on an endorsed permit (or a requested single_stage permit).
  2. Review the application, the map lens, and the Conflicts list. Optionally add stage2_controls (stamped "Added at approval") and a note.
  3. If there are red conflicts: the decision bar shows a bordered acknowledgement box — tick "Approve despite N red conflict(s)" and type a reason. Approve stays disabled until the box is ticked and the reason is non-empty. The reason is folded permanently into the approval note ("Approved despite N red conflicts: …"). Amber conflicts only inform — they never gate.
  4. Press Approve. The system calls POST /api/permits/applications/{id}/approve and moves the permit to approved ("approved for tomorrow, not yet live"). The approve_permit Task auto-resolves.
  5. The permit now sits in approved, awaiting the dual-signature walk-down (issue + countersign) — which is a field act, not yours unless you are appointed/the receiver.

Note: approved is the state most permits spend the night in. The issuer's /issue signature does not make it live — it leaves status at approved and sets a derived "awaiting countersign" flag. Only the named receiver's countersign flips it to issued. Don't expect Approve to put work on the ground.

Task 3 — Close out a live or expired permit

  1. In the Permits panel (P), open a permit in the "Live" section (issued) or "Expired — unclosed" (expired). You can drive close-out via the coordinator-band path you inherit — no appointment needed.
  2. Trigger close-out (the "Close out" Task card in My Work routes here too). The close-out surface shows a fixed 3-item safety checklist: "Work complete and all tools / plant removed", "Work area made safe, clean and checked", "Fire watch held to completion (where required)".
  3. Tick all three (the "Sign & close out" button stays disabled until all are confirmed), add an optional note, and press Sign & close out. The system calls POST /api/permits/applications/{id}/close-out, moves the permit issued/expired → closed, and re-snapshots the PDF.

Note: Close-out is blocked while the permit has open child permits (any child not in closed/rejected/withdrawn). The rejection surfaces inline and names the blocking child permit numbers — close the children first.

Note: expired ≠ closed. The scheduler only flips an overdue issued permit to expired (an alarm); it never closes it. An expired permit still needs your close-out sign-off, and — if the close-out gate is on — it actively blocks that contractor's next permit from being issued until you close it.

Task 4 — Decide an extension request

  1. Extensions live in the detail body, not in Tasks. Open the permit (it must be issued and an extendable type — UK default: only General) and find the pending extension request row.
  2. Review the requested new end time. Press Approve (POST /api/permits/extensions/{request_id}/approve) — this moves permit.end_at in place (no status change, the permit stays issued), records an extend_approve signature, and re-arms the expiring-soon warning. Or press Reject (reason required) to leave the window untouched.

Note: Extension is stretch-in-place, not reissue. High-risk types (HW/CSE/WAH/EXC/ELE) are not extendable — a request can only exist on a live General permit. A spent/expired permit cannot be extended; it must be reissued (a fresh draft) instead.

Task 5 — Configure the permit programme (types, appointments, policy)

  1. In the Permits panel header, click the gear (data-test-id permit-settings-gear, tooltip "Permit settings"). It only renders for HSE/admin (gated on permit_settings_manage) — which you are. The "Permit settings" modal opens with three tabs: Types, Authorised issuers, Policy.
  2. Types tab — create/edit/retire PermitTypes. The code (e.g. HW, CSE, EXC) is what the permit-conflict clash rules match on, so adding a new permitted activity usually means also authoring a matching clash rule. Set capability flags: requires_parent, is_extendable, single_stage. Retire a type by setting it inactive (active=false) — there's no hard delete. (requires_powra exists as a flag but is a reserved V2 hook — it enforces nothing in v1.)
  3. Authorised issuers tab — appoint a user as an issuer via POST /api/permits/appointments. An appointment is (user_id, optional permit_type_id): leave the type null to appoint them for all types, or scope them to one. Revoke via delete (revoked rows are kept for audit).
  4. Policy tab — edit SitePermitConfig as a full-state save:
    • Coverage policy (permit-policy-coverage) — surface-only. When on, active entities with no linked permit get a "No permit" advisory badge. It never blocks placement.
    • Close-out gate (permit-policy-close-out-gate) — the one hard stop. When on, a contractor with expired-unclosed permits cannot have a new permit issued until those are closed out. Approvals still proceed; the block fires only at issue time.
    • Nominee / Deputy nominee selects and Expiring-warning lead (hours, 1–720) input (default 24) — these feed the notification audience and the expiring-soon timing, not the two headline behaviours.
    • Press "Save policy". The system does a full PUT /api/permits/config. On failure (e.g. a mid-session role downgrade 403s the write) the red error banner surfaces it.

Note: The Policy save is full-state replace, not partial. The UI loads the whole object then submits it back, so don't worry in the app — but if you script the API, you must send all five fields or omitted ones reset to their schema default (e.g. coverage_policy_enabled → false).

Note: There is no seeded config row. A brand-new site behaves as if both policies are off until someone saves the Policy tab once — the row materialises on first save. Don't go hunting for it before then.

Task 6 — Run an XLSX permit import

  1. Open the Imports panel (key I) → "Work Permits" accordion. The Upload button, format selector, and permit-sets list are visible to you because you hold contractor_manage.
  2. Click Upload, pick a customer permit spreadsheet (.xlsx/.xls). If more than one format is configured, choose it from the format dropdown; otherwise it auto-detects by extension. Today only the AFRY PTW Export format ships.
  3. The backend (POST /api/permits/upload) parses the sheet, auto-creates unknown contractors by name, resolves locations against site buildings/zones, and upserts permits by (site_id, permit_number). Imported permits land directly at status issued, source imported — they skip the whole approval chain.
  4. Read the result banner: new / updated counts, contractors created, unresolved locations. If you uploaded the batch before the buildings existed, click Re-resolve later to re-match locations without re-uploading.

Note: Wall-clock times in the spreadsheet are read in the site's timezone, not your browser's or UTC. Re-uploading the same file updates matching permit numbers in place (and re-points them to the newest set) rather than duplicating — the banner shows "N new, M updated".

What you'll be notified about

Trigger You get
Permit submitted (two-stage type) "Permit {number} awaiting endorsement" notification + an endorse_permit Task ("Review & endorse")
Permit submitted (single_stage type) An approve_permit Task ("Review & approve") directly — no endorse
Permit endorsed "Permit {number} awaiting approval" notification + an approve_permit Task ("Review & approve")
Extension requested Notification + email (you're in the stage-2 band that decides extensions)
Permit expired unclosed Notification + email (role hse/admin audience)

Because you sit in both review bands you receive both gate Tasks and notifications, and permit gate Tasks deep-link correctly from My Work into the right surface. One known gap: clicking a permit notification (the bell, not a Task) does not currently open the Permits panel — the in-app notification click-router has no permit case, so it logs an "Unhandled" warning and no-ops on navigation. Use the My Work panel (which does deep-link) to act on permits; treat the bell notifications as informational only.

Gotchas

  • You can't issue or countersign by being admin. Issuing requires an active PermitAppointment for the permit's type (or an all-types appointment) — appoint yourself on the Authorised issuers tab if you genuinely need to sign walk-downs. Countersigning requires you to be the named receiver (permit.receiver_id). These two are deliberate separation-of-duties gates that role alone never satisfies.
  • Stage 2 is the separation-of-duties line you straddle. A coordinator can endorse but cannot approve; you hold both bands, so be deliberate about not self-approving your own coordination check unless your site's process allows it. Stage-1 = coordination check, stage-2 = H&S desk review — they exist to be two sets of eyes.
  • You can see in-review permits across all contractors, but not other users' private drafts. A not-visible permit returns 404 (not 403), so existence is hidden. You can act on the whole in-review middle and everything site-public (issued/expired/closed), just not author someone else's WIP.
  • Adding a permit type ≠ adding a clash rule. The clash engine matches on PermitType.code (e.g. HW, CSE) via the permit_type_in_a/permit_type_in_b predicates, and codes are case-sensitive. If you create a new high-risk type and want it to conflict with others in proximity, you must author the matching clash rule yourself — creating the type does not auto-generate one.
  • The close-out gate blocks at issue, not at approval. If a contractor has expired-unclosed permits and the gate is on, you can still endorse and approve their next permit — it only fails when the appointed issuer tries to make it live. The error names the specific blocking expired permit numbers. Coordinator-raised permits with no contractor are never gated.

For Superadmin (platform operator)

Your role

You are the platform operator — a user with is_superadmin = True. You sit outside the per-site role system entirely: your is_superadmin flag short-circuits both _has_perm (so every permit permission check returns true) and _assert_owner (so the requestor-ownership gate never stops you). You can see every permit on every site — including other users' private drafts — and you can drive every desk gate in the lifecycle: endorse, approve, reject, request corrections, close out, reissue, and decide extensions. You can also configure the whole subsystem (permit types, authorised-issuer appointments, and the per-site SitePermitConfig policy). There are exactly two things your god-mode does not unlock, because they are deliberately gated on identity and physical appointment rather than permission: you cannot issue a permit at the walk-down unless you hold a PermitAppointment, and you cannot countersign unless you are the permit's named receiver. Everywhere else, treat yourself as able to act — your job is operating and administering the platform, not normally sitting in the day-to-day permit chain.

What you can and can't do

You can
  • See every permit on any site, in any state, including other users' private draft permits (you read via the review-band visibility path, so nothing 404s for you).
  • Drive every desk gate regardless of role band: endorse (stage-1), approve (stage-2), reject, request corrections, close out, reissue, and approve/reject extensions.
  • Edit, submit, withdraw, or delete any user's draft — _assert_owner short-circuits for you (no PermitOwnershipError).
  • Configure the subsystem on any site: the PermitType catalogue, authorised-issuer appointments, and SitePermitConfig (coverage policy, close-out gate, nominee/deputy, expiring-warning lead).
You can't
  • Issue at the walk-down without a PermitAppointment. is_appointed_issuer() has no superadmin shortcut — it matches only a non-revoked appointment (type-scoped or all-types). Being superadmin does not place you at the workface.
  • Countersign unless you are the permit's receiver_id. The countersign identity gate has no superadmin bypass — even you must be the named receiver to flip a permit live.

Note: These two exceptions are not bugs to route around. They encode real-world accountability — the walk-down signature attests physical verification by an appointed person, and the countersign attests the receiver accepted the conditions. If you genuinely need to issue, appoint yourself first (you hold permit_settings_manage); if you need to countersign, you must be set as the receiver on that permit.

Your tasks

Task 1 — Find and inspect any permit on any site

  1. Select the site whose permits you want from the left site browser (key B), then open the Permits dock panel with key P (tooltip "Open permits (P)"). The panel hosts the state-grouped board.
  2. The board groups permits into Live, Approved — tomorrow, In review, Expired — unclosed, and My drafts & corrections. As superadmin you see every permit in these sections — including drafts authored by other users, which a normal member would never see.
  3. Click any row to open the read-only detail workbench (permit-detail). This calls GET /api/permits/applications/{id} (which never 404s for you) and renders the header/meta, controls, pre-checks, the embedded document + signature chain, and any extensions.
  4. Selecting a permit casts the map lens (MapPermitLens) — linked entities glow, everything else dims to 0.18 opacity, no pin is dropped. If the lens lights up empty, the scrubber is parked outside the permit's window; click the "N tokens outside this time — jump to window" pill to snap the scrubber to start_at.

Note: Board selection is non-destructive in time — it does not move the scrubber. Only the review workbench snaps the scrubber to the permit window (and restores it on close).

Task 2 — Drive a desk gate (endorse / approve / reject / request corrections)

  1. Open the permit's detail footer or the review workbench (board row → the gate actions route into permit-review). Button visibility is driven by the permit's PermitPermissions flags, all of which compute true for you.
  2. To endorse a requested permit (stage-1): press Endorse. The system records a stage1_approve signature and moves the permit requested → endorsed. (Skipped for single_stage types.) API: POST /api/permits/applications/{id}/endorse.
  3. To approve (stage-2): from endorsed (or directly from requested for single-stage types), in the review decision bar acknowledge any red conflict — tick "Approve despite N red conflicts" and type a reason — then press Approve. Approve stays disabled until the red conflicts are acknowledged. The reason is folded into the permanent approval note (stage2_approve signature). State: → approved. API: POST .../approve.
  4. To reject (terminal) or request corrections (bounce back): both require a note/reason in the decision bar. Reject → rejected (POST .../reject); request corrections → corrections_requested, editable by the requestor, chain restarts on resubmit (POST .../request-corrections).

Note: Amber conflicts only inform — they never gate Approve. Only red conflicts demand the acknowledgement + reason. Clashes are always advisory; they never hard-block a decision.

Task 3 — Close out a live or expired permit

  1. From the permit's detail footer (or its My Work card), choose Close out to open the permit-close-out ceremony.
  2. Tick the fixed 3-item safety checklist — Work complete and all tools / plant removed, Work area made safe, clean and checked, Fire watch held to completion (where required). The Sign & close out button stays disabled until all three are confirmed.
  3. Press Sign & close out. The permit moves issued|expired → closed, the PDF is re-snapshotted, and the board re-lists. API: POST .../close-out.

Note: Close-out is blocked while the permit has open child permits (children not in {closed, rejected, withdrawn}). The rejection surfaces inline and names the blocking child numbers — close the children first.

Task 4 — Decide an extension request

  1. Open the permit detail; the extension request lives in the projected Extensions section (not a Task — extensions are not deep-linked from My Work).
  2. On the pending row, press Approve (or Reject with a required note). Approve moves permit.end_at in place — the status stays issued, this is not a status transition — records an extend_approve signature, and re-arms expiring_warned_at. API: POST /api/permits/extensions/{request_id}/approve (or .../reject).

Note: Extensions only apply to issued permits whose type is is_extendable (UK default: only General). A child's extended window must still sit inside its parent's. A spent/expired permit cannot be extended — it must be reissued.

Task 5 — Reissue a spent permit

  1. Open the detail of an issued, expired, or closed permit and press Reissue.
  2. The system clones it into a fresh draft (source = requested, reissued_from_id set, copying type/parent/contractor/description/controls/window/pre-checks) and opens the apply workbench. The original permit is unchanged. A new number is minted on the clone's own submit. API: POST .../reissue (returns 201).

Task 6 — Configure the subsystem (types, appointments, policy)

  1. In the Permits panel header click the gear (title "Permit settings", data-test-id permit-settings-gear) to open the 3-tab Permit settings modal: Types, Authorised issuers, Policy.
  2. Types tab — create/edit/retire PermitTypes. The code (e.g. HW, CSE, EXC) is what the clash engine matches via permit_type_in_a/_b; adding a new type means adding a matching clash rule if you want it to conflict. Retire with active = false (no hard delete). API: POST/PATCH /api/permits/types.
  3. Authorised issuers tab — appoint or revoke PermitAppointments (user_id, optional permit_type_id); a null type = appointed for all types. This is also how you appoint yourself to gain issuing rights (see the gotcha below). API: POST/DELETE /api/permits/appointments.
  4. Policy tab — edit SitePermitConfig with a full-state PUT: the Coverage policy switch (surface-only badge, never blocks), the Close-out gate switch (the one hard stop — blocks new issues for a contractor with expired-unclosed permits), Nominee / Deputy nominee selects, and Expiring-warning lead (hours, 1–720). Press Save policy. API: PUT /api/permits/config?site_id=....

Note: The Policy tab is a full-state replace — the UI loads the current config then submits the whole object. If you script PUT /permits/config directly, send all five fields or you'll silently reset the omitted ones to schema defaults (e.g. coverage_policy_enabled → false).

Note: There is no seeded SitePermitConfig row — an absent row means both policies off. The row materialises on the first Save policy. Don't go hunting for a row that doesn't exist yet on a fresh site.

What you'll be notified about

The permit notification rules engine is keyed off role bands and named identities (stage-1/stage-2 bands, requestor, receiver, appointed issuers, nominee/deputy) — not off the superadmin flag. So in normal operation you will not receive permit gate Tasks or lifecycle notifications simply for being superadmin. You only get permit notifications/Tasks when you are also in the relevant audience for a specific permit — for example, if you are the named receiver on a permit you'll get the permit_awaiting_countersign notification, or if you raised a permit yourself you'll get its outcome pings. The four gate Tasks (endorse / approve / issue / close-out) land on the bands, so to see them in My Work you'd need the corresponding membership role on that site, not just superadmin.

Note: Clicking a permit notification does not currently open the Permits panel — the notification click-router has no permit case, so a permit notification logs an "Unhandled" warning and does not navigate. Permit Tasks (in My Work) deep-link correctly; permit notifications are informational only for now.

Gotchas

  • Issuing is the hard wall in your god-mode. POST .../issue checks is_appointed_issuer(you, site, type) with no superadmin shortcut — if you hold no PermitAppointment for that type (or an all-types one), you get PermitGateError "Issuing requires an authorised-issuer appointment". To issue, go to Permit settings → Authorised issuers and appoint yourself first (you have permit_settings_manage).
  • Countersign is identity-locked. Only permit.receiver_id may countersign — there is no superadmin bypass. You cannot rubber-stamp a permit live on someone's behalf; the permit only goes issued when both an appointed issuer signs (/issue) and the named receiver countersigns (/countersign).
  • /issue does not make a permit live. It records the issuer signature and leaves the status at approved (the board/detail shows a derived "awaiting countersign" flag — there is no separate enum state). Only the receiver's countersign flips it to issued. Don't expect a status change after a walk-down signature alone.
  • expiredclosed. The scheduler sweep flips overdue issued → expired automatically (an alarm state) but never closes a permit — a human close-out sign-off is still required to reach closed. Expired permits are still reissuable, and (if the close-out gate is on) they block the contractor's next issue.
  • Even with full visibility, remember the two intake paths share one Permit entity. Imported (XLSX) permits are inserted directly at issued with source = imported and no requestor_id — they never had a draft/approval chain, so their detail won't show one. Don't read a missing approval chain as a data fault on an imported permit.
  • Your edits to another user's draft work (ownership is bypassed), but mind the blast radius — you are editing someone else's in-flight application. Prefer request corrections (bounces it back to the author to fix) over silently editing their draft, unless you're deliberately operating on their behalf.

Site policy — coverage & close-out gate

SitePermitConfig — at most one row per site. An absent row means both policies OFF; the row is created lazily on the first save (PUT /permits/config is a full-state replace — omitting a field resets it to default). In the UI it lives behind the Permits panel header gear ("Permit settings", permit-settings-gear), visible to hse/admin only, which opens a 3-tab modal (Types / Authorised issuers / Policy) rendered at the dashboard root (the panel's backdrop-filter would clip a fixed dialog).

Setting Type Default Effect
coverage_policy_enabled bool false Surface-only. Badges active in-window entities (workers/plant/deliveries) with no linked permit — noticeboard panel, map decoration, "No permit" properties chip. Never blocks placement or editing.
close_out_gate_enabled bool false The one hard stop. When on, a contractor with expired-but-unclosed permits cannot have a new permit issued until those are closed out. Enforced in issue() only — approvals still proceed. Coordinator-raised permits with no contractor are never gated.
nominee_user_id UUID | null null Added to the permit_endorsed notification audience (#302). Does not change the two headline behaviours.
deputy_nominee_user_id UUID | null null As above — deputy notification audience.
expiring_warning_lead_hours int (1–720) 24 How far ahead the "expiring soon" warning fires and the board's "Soon" badge appears. Validated both sides.

Importing permits from a spreadsheet (XLSX)

A coordinator uploads a customer spreadsheet via Imports panel → Work Permits.

Config-driven parsing:

  1. POST /permits/upload selects a parser by format_id, or auto-detects by .xlsx extension when omitted.
  2. A per-customer YAML config (backend/app/permit_format_configs/*.yaml) maps spreadsheet headers → permit fields (case-insensitive, whitespace-stripped match) and applies named transforms: parse_datetime, split_values, strip, to_string, regex_extract.
  3. Wall-clock cells are interpreted in the SITE's timezone — the YAML's timezone arg is always overridden by the site tz.
  4. Each row is resolved: contractor matched by name (auto-created with a deterministic colour if unknown); location/zone strings matched to buildings/zones with level parsing validated against the building's actual levels.
  5. Permits are upserted by (site_id, permit_number) into a new PermitSet, landing at status issued, source imported, with no requestor. Re-uploading the same file updates in place (banner shows "N new, M updated"), never duplicates.

Note: Only one customer config ships (afry_ptw.yaml, format_id afry_ptw_xlsx). There is no UI to add configs — they are YAML files. Present the AFRY layout as "the" format, not a self-service feature. Rows with blank permit numbers are silently skipped; rows with unparseable dates are skipped and reported in skipped_invalid_dates. Use Re-resolve to match permits uploaded before their buildings existed.

Recurring-occurrence generation (buildPermitScheduleOccurrences, frontend, when an issued permit seeds entity creation):

  • isMultiDayPermit is duration-based (>24h), not calendar-based — a single overnight shift (16:00→00:00) is one occurrence.
  • A multi-day permit emits one occurrence per calendar date using the permit's own HH:MM (shiftIndex: -1, custom time).
  • Overnight trim: if end_time < start_time, the trailing calendar date is dropped (Mon 22:00 → Sat 06:00 = 5 occurrences, Mon–Fri).
  • Weekend awareness: if either endpoint falls on a Sat/Sun, the day filter expands to include weekends; otherwise weekdays only.

Permits guide: Overview · Applying · Reviewing & approving · On-site signing · Configuring · Reference

Generated from source · SimOops living system reference