System reference · spine

Glossary

The canonical domain vocabulary — generated from source, never hand-edited.

generated from CONTEXT.md a82242488d47287 terms

Naming

SimOps (public brand) vs SimOops (internal codename)
Decided 2026-07-07 (marketing-site grill): the product's public-facing name is SimOps — one "o", matching the sim-ops.io domain — used in all marketing copy, the app UI wordmark, docs prose, and customer-facing documents. SimOops (double "o") is the internal codename only: the repo, package names (@simoops/*), database names, container names. Never mix the two in one audience: customer-facing text always says SimOps; code and infra identifiers stay simoops. _Avoid_: "SimOops" in any public copy; renaming code identifiers to match the brand.

Spatial / map entities

Primitive entity kinds
The three first-class on-map entity primitives in the model today, distinguished by their geometry shape and lifecycle:
Auxiliary pin
A *display-axis* classification, distinct from the storage-axis "auxiliary point primitive" above. An auxiliary pin is an on-map entity whose UX shape is: pointlike geometry, rendered as an icon pin (not text), opened via click→properties-panel (read-only data + per-kind workflow actions), with the edit modal reached via the panel's Edit button or E key. Auxiliary pins are not members of SpatialEntityCollection (no multi-select participation) but do participate in SelectionService via a single selectedPin$ : {kind, data} | null slot, mutually exclusive with the entire spatial axis — both the Multi-selection's primary and its multiSelection map. Selecting a pin clears any worker / plant / area / road selection (single or multi); selecting any spatial entity clears the pin. Modifier-key clicks on a pin are silently treated as plain-click (the pin axis doesn't support add/toggle since pins are solo by construction).
Position provenance
How a Auxiliary pin's point was obtained — recorded only on the field-capturable pins (Observation, Incident, Blocker and Snag — all WorkGroup-backed, mixing GeolocatedPin). Four sources (PositionSource): manual (a person placed or dragged the pin on the map), device_gps (the capturing phone's geolocation fix), photo_exif (GPS tags read from an attached photo), and inferred (no fix was available so the system placed the pin — the site-centroid fallback on the anonymous QR form; to be repositioned at triage). Carried with an optional accuracy radius in metres. The coordinator-placed pins (delivery / alert / poi) have no provenance — they are always, implicitly, manual. _Avoid_: source (overloaded), origin; geotag (the act of tagging, not the recorded property).
Inferred location
A Position provenance of inferred: no fix was available, so the system placed the pin — the site-centroid fallback on the anonymous QR capture form. It marks a position nobody chose; triage repositions it (see Observation triage). Surfaced as *Placed at site centre · nobody chose this location* on the panel's Location row, carrying the Position rectification verb, and as a Site centre tell in list rows — it always counts as a Rough position (amended 2026-09-09, map #3779). _Avoid_: using this word for a GPS/EXIF point — that is a Sensed location.
Sensed location
A Position provenance of device_gps or photo_exif: the point came from a sensor rather than being deliberately placed, so it is approximate and carries an accuracy radius. It is surfaced to users *as* approximate — a "Approximate · ±N m / from device GPS" Location row on the properties panel of both pin kinds (an escalated incident's reads *from the observation's device GPS*), a subtle radius ring on the map, and — above the Rough position band — a nudge on the capture card and a ±N m tell in list rows. Manually correcting a sensed pin (drag, edit or Position rectification) reverts its provenance to manual and clears the radius; the original fix survives only on the pin's position-update timeline event, rendered as a history line in the panel's audit section (amended 2026-09-09, map #3779). _Avoid_: estimated, fuzzy, guessed; "GPS pin" (conflates the source with the pin); inferred (the enum's name for the system-placed fallback above).
Position rectification
The deliberate correction of a pin whose point nobody chose — a Sensed location or an Inferred location — after capture, ending in the manual provenance with the radius cleared. Three doors, one rule: the desktop Set exact location verb on the panel's Location row (observation and incident; arm-then-click place mode; drag remains the power-user path), the mobile Adjust location door on the quick-capture and geofence done screens (the reporter's own just-sent pending row only), and the also move the incident one-tap prompt offered when an escalated observation is rectified while the two pins still coincide (one request, incident can_edit, review block absolute, lock ignored). Authority is the kind's can_edit unchanged; a locked row says *Unlock to move*; a reviewed incident hides the verb. Never a new provenance value — the correction is a history fact on the position_updated event. Decided 2026-09-09 (map #3779); ADR 2026-06-10-inferred-location-provenance D6 amendment. _Avoid_: "move pin" (a universal verb declined by name — the verb sits beside the words that explain why the pin needs it); relocate (the report/locate handoff); rectified as a provenance value.
Rough position
A pin position the platform does not trust to be where the reporter stood: a Sensed location whose accuracy radius exceeds ACCURACY_CEILING_M (100 m — the same ceiling the nearby-work ranker draws; provisional pending #3785), or any Inferred location. One constant, two tells that can never disagree about a row: the reporter's capture-card nudge (*Location is rough (±N m)* with *Set on map* promoted and a bounded *Try again*; above 1 km the phone's approximate-location setting is named and the retry is dropped — soft, never a gate) and HSE's list tell (±N m / Site centre, muted text with a pin glyph in the row's indicator cluster, never a pill, no noticeboard item, no notification). Derived from the live provenance pair, so Position rectification clears it at once. Decided 2026-09-09 (map #3779). _Avoid_: inaccurate (the radius is honest — the point is uncertain, not wrong); approximate unqualified (every sensed point is approximate; rough is the band above the ceiling).
Multi-selection
A set of zero-or-more SpatialEntitys the user has explicitly grouped for *bulk operations* (delete, hide, lock, move-to-floor, reassign-contractor). Membership is kind-agnostic by design — worker, plant, area, road can mix freely. Auxiliary pins are deliberately excluded (see "Auxiliary pin" above and ADR 2026-05-21-auxiliary-pin-interaction-model.md D5); their interaction model is click→panel→workflow-actions, not bulk ops. Selecting a pin (any modifier) wipes the multi-selection — the spatial axis and the pin axis are full-mutex in both directions.
Bulk-action strip
The UI affordance that takes over the side panel when the multi-selection contains entities of more than one kind. Lists the bulk operations that apply across the mixed set (delete, hide, lock, move-to-floor, reassign-contractor) and nothing else — no per-entity properties. To edit an individual entity, single-click it out of the strip.
Disambiguation picker
A small popover that appears when a click lands on a stack of overlapping entities (>1 hit at the click point). Lets the user pick which one(s) to act on instead of guessing layer order.
Floor focus
A focused building floor acts as a selection lens: while it is active, every spatial gesture (single-click, box, lasso, Disambiguation picker, right-click, drag) and every attribute-based selection tool (by type, by contractor, by query) acts only on entities on the focused floor. With no floor focused, the site default applies — ground-level entities are selectable, upper floors are not shown. An entity counts as on the focused floor if it shares that floor in the same building *or* sits at the same physical elevation in a neighbouring building. Travelling *to* an off-floor entity happens off the map: selecting it from the Site member roster list, Site browser, or search focuses its floor and flies to it. Those non-spatial paths are deliberately exempt from the lens.
Off-floor hint
An on-map entity rendered dimmed because it sits on a building floor other than the one in Floor focus. It exists purely as a spatial cue — "something is over here on another floor" — and is fully inert: not hoverable, clickable, draggable, selectable, or reachable through the Disambiguation picker. To act on it you navigate to its floor (or reach it from the roster/search). Spans worker, plant, and ActiveArea; Auxiliary pins carry no floor and are never hints. Decided 2026-05-28 (grill session). _Avoid_: ghost (overloaded — the audit overlay renders "ghosts"), faded token (worker-specific; the concept is kind-agnostic), dimmed entity (names the rendering, not the inert-ness).
Noticeboard
The dock panel of live operational items grouped by kind — Alerts, deliveries, the permit-coverage advisory, and Clashes (split "relevant" vs "other" by the viewer's vantage). The operations board — distinct from the Health & Safety panel.
Health & Safety panel
The dock citizen hosting the site's H&S records — Observations, Incidents, Inspections, Actions and HSE register sections — opened with H. It presents full screen: H lands on a launcher grid of register tiles, each drilling in to its section at width. Distinct from the Noticeboard. Note the asymmetry: the *Incidents section* is one of five — the panel as a whole is Health & Safety, not "incidents".
Work home
The dock citizen for the Work domain's *scheduled work over place and time* (W, rail beside the map) — tabs Plan (the authoring grid, formerly the Work plan panel) / Reality (the read-only planner view carrying the Plan overlay, whose place labels open the Place drill): two modes — authoring / reality — of one domain object; the micro view (Region evolution) is Reality's drill, not a third tab (amended 2026-09-01, fold ADR 2026-09-01-region-evolution-folds-into-reality.md). The map door's Changes here view borrows the rail without a tab. The Plan tab carries an all-roles expand affordance opening a fullscreen planning workspace (which absorbs the sync console); every row on Plan and Reality renders at Two densities. Decided 2026-08-12 (wayfinder map #2379, docs/decisions/2026-08-12-dock-domain-homes.md). _Avoid_: Work plan panel (now the Plan tab), Analysis (the dissolved suite that held Reality and Region evolution), Region-evolution tab (retired).
Commercial home
The dock citizen for the Commercial domain's records (I, the second full-screen big-surface citizen beside the Health & Safety panel): a launcher grid of five tiles — Evidence (Evidence packs), Completions, Defects, Inventory, Lost time. The Lost time tile is a *resolving door*: membership picks the site-wide ledger or the own-firm position view (header always carries a "Your firm" / "Site-wide" scope label). Named plain Commercial — homes name domains, not roles; the button is entity.view-visible by design. Decided 2026-08-12 (wayfinder map #2379). _Avoid_: QS home / Commercial-QS (role-named), Analysis (the dissolved suite that held Evidence and Lost time).
Map aid
A data wash a surface paints on the map — the lost-time choropleth, the stock heat map, the INV-R24 scope wash. Aids persist after their surface closes (one lifetime rule, no per-aid exceptions): a labelled dismiss chip rides the map; ✕ clears the aid, the chip body reopens the owning surface unscoped, and clicking a painted place reopens it *scoped to that place*. Single occupancy — activating an aid replaces whatever persisted, so the place-click owner is always unambiguous. Per-site data; a site switch clears it. Decided 2026-08-12 (docs/decisions/2026-08-12-map-aid-handoff.md). _Avoid_: overlay (the map's layer vocabulary), aid clearing on surface close (the pre-#2400 contract this replaces).
Docked mode
The lost-time drill-in's second presentation: a standard left rail panel beside a live map, entered by "Dock to map" on the fullscreen drill-in header and left by "Expand" back (the Work-plan workspace's Expand pair in reverse). Drill-in state — scope arm, filters, feed-row ↔ place brushing — carries across the swap; Escape closes it like any rail panel. Not a dock citizen: no button, no keybind, reachable only through the Commercial home's Lost time section. Decided 2026-08-12 (docs/decisions/2026-08-12-map-aid-handoff.md). _Avoid_: split mode / half-screen (a third presentation class this decision declined to mint).
Site browser
The dock panel that lists the site's on-map objects grouped by kind — as a *non-spatial* way to find a thing and travel to it — covering both the selectable primitives (Workers, Plant, ActiveAreas, roads, PointOfInterests) and the Reference features building and zone under their own groups (ordered buildings → zones → roads, most- to least-prominent substrate). Together with the Site member roster and search, it is one of the off-map navigation paths deliberately exempt from the floor-focus selection *lens*.
Association snapshot
A one-shot read of the current multiSelection, used to pre-populate a domain modal that associates entities with a parent — incidents (involved_entities) and Smart Groups today. Entities outside the domain's accepted kinds (e.g. road for incidents) are filtered out and surfaced as a "skipped: N" hint, not silently dropped.
Variant
A value on a primitive's variant enum (e.g. PlantType.crane, FeatureType.work_area). Variants influence rendering, clash semantics, and creation-flow rules, but share the primitive's table/geometry shape.
Phase
A cross-cutting classification applied *across* primitives — values are construction | commissioning | handover. Decided 2026-05-19 (grill session): phase is derived from the variant by default, with an explicit nullable override when the variant doesn't determine phase uniquely. The mapping variant → default_phase is the single source of truth; the override exists for genuine independence cases (a WorkerType.construction worker doing handover snagging; a crane reused across phases).
Group
Two existing concepts that the codebase already names "group":
MAP_ENTITY_REGISTRY
A frontend TS const (no backend counterpart) keyed by MapEntityKind, holding per-kind hooks for the *behaviour* axis of on-map entities: drag, lock, hide, position update, floor-aware drop, drag-eligibility under workflow status. Membership is the union of every on-map kind that participates in those behaviours: worker | plant | road | area | poi | delivery | alert | incident | observation | blocker | textlabel. Each entry exposes getSelectedEntity(selection), getLocked(entity), canDrag(entity), toggleLock(entity), toggleHide(entity), updatePosition(entity, lonLat), plus per-kind constants (defaultLocked, floorAware).
Variant metadata
Per-primitive registries hold variant behaviour and identity. The existing FEATURE_TYPE_REGISTRY is the shape to follow. WORKER_TYPE_REGISTRY exists backend and frontend; PLANT_TYPE_REGISTRY exists frontend-only so far (the frontend registry file records this). Each per-primitive *TypeSpec carries the fields meaningful to that primitive (e.g. requires_levels on FeatureTypeSpec; WorkerTypeSpec today carries only its variant).
Interaction mode
What a click on the map does *right now* — the single active modal behaviour, one of: idle (normal selection), creating a primitive (worker / plant / area / road), placing an Auxiliary pin (delivery / poi / alert / incident / text), measuring, positioning a floor plan, or picking an analysis anchor. Exactly one is active at a time; the modes are mutually exclusive by convention, not by an enforcing owner. Decided 2026-05-29 (grill session): the mode is a *derived aggregate* — computed from the several services and component inputs that each own one mode, surfaced as one value under a fixed precedence — not a single owned state. The "in some placement/creation mode" predicate the map's cursor and click-short-circuit logic keys off derives from it. See docs/decisions/2026-05-29-map-event-wiring-host-decomposition.md. _Avoid_: creation mode (only one variant of it), placement mode (overloaded — names both the aggregate and the auxiliary-pin subset).
Geometry authoring
The one place a shape gets edited and committed, whatever kind wears it — a reference feature's whole-building polygon, one building level's footprint, a site Area's MultiPolygon, a road's vertex list. Those kinds persist through different endpoints into different local stores, but the *gesture* is one gesture: a shape is drawn, dragged, reshaped, cut or reverted, and something has to decide where the resulting ring lands, what undo step it earns, and which store learns about it. That decision has one owner, GeometryAuthoringService (ui/src/app/services/geometry-authoring.service.ts) — layer/feature CRUD, hole cutting (targeted and freeform), the active-feature lock, and the single subscriber to VertexEditService.results$ whose router fans to all three kinds. Decided 2026-08-25 (grill session, #3364): authoring is a *different question* from selection — *which* thing is selected, which FeatureSelectionService owns. The two meet at exactly two one-way points: the selection tells authoring which feature the cut candidates are now about (refreshPendingCutsFor), and authoring *reads* the current selection through SELECTED_ACTIVE_FEATURE rather than taking the selection service as a dep. Nothing flows back. _Avoid_: "reference-feature geometry" (the router is not reference-feature-only — naming it for one kind splits it across two modules), "feature editing" (overloaded — a feature's *attributes* are edited in a modal, which is a different gesture).

Clash engine seams

Clash rule identity
Every clash identifies the rule it violated via a non-empty rule_name — the canonical "which rule was breached" label, surfaced in the UI with the hand-raised glyph. This is an invariant, not an optional field: the engine always sets it from the rule's name, and it is NOT NULL wherever it is persisted (ClashStance). Decided 2026-06-02 (grill session): a missing rule_name is treated as impossible, not survivable — there is no UI fallback; it is enforced by a required frontend type and a backend guard against empty names.
Clash category
A per-variant declaration of *how a variant participates in clash rules* — distinct from the variant itself. Current members: physical_worker, live_hazard, mobile_plant. Decided 2026-05-19 (grill session): rules filter on clash category, not on enumerated variant lists. Adding a new variant means declaring its category; existing rules don't churn. The category lives on the variant's spec entry in the per-primitive registry.
Enclosure
The containment context an entity occupies for proximity purposes: the building it is placed inside (its building_feature_id), or the open air when it has none (building_feature_id is null). Two entities share an enclosure when they are in the *same* building, or *both* outdoors; a building wall is the boundary between enclosures. "Inside a building" means the building_feature_id FK is set — an explicit placement-with-a-level — not the mere geometric overlap of a footprint over a building outline.

Clash lifecycle and consequences

Clash episode
The persisted, first-class record of one clash over its lifetime — born when a specific conflict first appears, cleared when it ends. Identified by the same tuple as a clash stance: (site_id, entity pair, hazard) — where the hazard is coalesce(signature, rule_name) (CLASH_IDENTITY_SQL; rule_name survives only as the fallback for rows the signature backfill could not resolve; see Clash rule identity). Distinct from two neighbours:
Clash event
The edge-triggered DomainEvent a Clash episode emits at a lifecycle transition, consumed by the RulesEngine to fire a Clash consequence. Six kinds, in two groups by what drives them:
Clash consequence
The effect fired when a Clash event matches a rule — a Notification, Task, or email (any Channel the engine supports). Reuses the notification RulesEngine wholesale: a clash consequence is a NotificationRule keyed to its clash rule by FK, authored inline on the clash rule (one surface) but dispatched by the existing engine (one path). A create_task consequence ("you need to resolve this") auto-closes on clash_cleared and is manually closable by a coordinator stance.
Clash policy matrix
Historical (surface retired #671). The matrix-shaped authoring lens over the clash-rule set — a permit-code × permit-code grid per token-type pair — was retired as a standalone surface; per-site clash policy is now authored in the per-rule editor, whose save decision seam (ui/src/app/utils/clash-rule-save.ts, #649/#654) forks a seeded global rule into a per-site override (never PATCHing the seed) and authors per-site suppressions. The matrix *concept* survives as the per-rule permit matrix (#662): an optional permit_matrix on a single rule refining which permit combinations fire and at what severity/distance. What the retired surface got right is retained by the engine: distinct hazards each surface (one clash per (entity pair, predicate signature)), and within a signature the winner is most-specific → per-site → most-severe → priority. That resolution model is documented once, in root CLAUDE.md → "Per-pair clash emission & winner selection" and ADR 2026-06-29-permit-aware-clash-matrix-and-consequences.md (D8 + the distinct-hazards correction). The full-token-axis vocabulary — the real clashable entity_types (worker variants, plant types, active areas, road; never the legacy label worker, which no adapter emits) — is ADR 2026-06-30-clash-policy-matrix-full-token-axis-and-clash-types.md.
Clash suppression
A per-site off switch for a seeded clash cell. Authored in the per-rule editor as "Off for this site" (the create-suppression / clone-profile-create-suppression decisions in ui/src/app/utils/clash-rule-save.ts, gated to canManagePermitConfig), it compiles to a ClashRule with suppresses = true that carries the shadowed seed's filters and rides the per-site axis to outrank that seed: the engine *claims* its signature on the pairs it matches but emits no clash, so the cell is dark for that site alone. Because the claim is signature-scoped, turning a baseline cell Off silences only that hazard — a distinct permit hazard on the same pair still surfaces (it has a different signature); a more-specific rule of the *same* predicate likewise still outranks the suppression. A suppression only ever silences a pair that would *actually* clash. Decided #4, 2026-06-30. _Avoid_: "disabling the rule" — a disabled (enabled = false) rule is invisible to the engine, so the seed keeps firing; suppression is the deliberate, visible per-site silence.

Rule version history

Rule version snapshot
The frozen, full JSON state of an authored rule captured on every mutation (create / update / enable / disable / clone / delete) — the unit the editor's history view, diffing, and (clash today) revert read back. Append-only; the version row carries no FK to its rule, so history survives the rule's deletion (the delete-snapshot is written *before* the row goes). Per-rule version numbers are monotonic, allocated under a per-rule advisory lock. Spans all three authored-rule families — the ClashRule, the NotificationRule, and the ThresholdRule — which share this lifecycle (seeded-immutable-except-enable/disable, clone-to-customise) but not their field sets. _Avoid_: revision (reserved for DiscussionCommentRevision); audit log / audit trail (the durable AuditLog / GDPR store — a version snapshot is rule-editor history, not the site audit trail).
Rule version log
The shared seam every authored-rule family records through — RuleVersionLog over a RuleVersionAdapter port (backend/app/services/rule_versioning/). The port carries the one genuinely per-kind thing — the snapshot projection — plus the version model and operation enum; revert (rebuild a rule from a snapshot) and change-summary are *opt-in capability adapters* (clash supplies both today; the notification/threshold twins gain them by adding a hook, never by editing the core). The pure dict-diff is shared and per-kind-agnostic. Replaces the three hand-rolled version services. See docs/decisions/2026-06-13-rule-version-log-consolidation.md. _Avoid_: version service (the superseded per-kind hand-rolls); rule history (too vague — names the data, not the seam).
Rule shape
The portable clash-rule field set and its one owning module — backend/app/services/clash/rule_shape.py. FIELDS is introspected from the ClashRule model minus an explicit excluded set (row id + timestamps), so a field added by migration is carried by default; IDENTITY (site_id, profile_id, source — where a rule *lives*, not what it *says*) is explicit for the revert/seeder skip policies. The interface is to_snapshot / from_snapshot only — additive writer, tolerant reader (a key missing from an old Rule version snapshot hydrates to the model column default; stored snapshots are never migrated). Every ClashRule reconstruction site consumes it (the version adapter's projection + revert, the at-time rule reconstruction, the boot seeder); compile() stays in RuleCompiler, which derives site_specific from site_id at compile time. _Avoid_: hand-enumerating ClashRule fields at a call site (the #1688 dropped- field defect class); rule schema (the Pydantic create/update surface, which stays schema-driven and is only guard-tested as a subset of FIELDS).

Surfacing — bringing site activity to users

Noticeboard
Dock panel today (opened via PanelStateService's dock-panel state, activeDockPanel === 'noticeboard'). Shows shared site state — alerts, deliveries, clashes — that is the same for every user with access to the site. No per-user state; if you and I both have the noticeboard open, we see the same rows. Its rows span both temporal-anchoring classes: the delivery and clash lists are Scrubber-anchored surfaces, the alert list is a Now-anchored surface.
Notification
A per-user inbox item representing something directed *at this user*. Has per-user lifecycle state (unread, read, dismissed). Surfaced via the bell icon + count badge in the top toolbar, opened as a dropdown. Distinct from Noticeboard: notifications are personal, the noticeboard is shared. The same underlying domain event (e.g. "new incident filed") may produce both: a Noticeboard row (everyone sees site activity) *and* a Notification per targeted user (the HSE coordinator gets pinged).
Push subscription
A per-device web-push registration belonging to a user — the row that lets SimOops reach a closed browser. User-global (no site axis: a subscription is a fact about a device, not a membership; site scoping stays in rules-engine audience resolution). One user has many (phone PWA, desktop browser). Only the four personally-actionable notification families push (Task minted for you / assigned to you, @mention, outcome of a thing you raised) — never the Noticeboard firehose. Decided 2026-07-18 (wayfinder map #1293); see docs/decisions/2026-07-18-web-push-channel.md. _Avoid_: push token (mobile-native connotation), device registration.
Briefing
A per-user, on-entry roll-up — the surface a user meets when they enter (or refresh) a site: "what do I need to know and act on right now, and where do I go for it". Composed of modular briefing cards, each summarising one underlying surface (active Alerts; work-stopping Clash blockers and *my* clashes, consolidated into one card; *my* open Tasks; the Notification inbox; open incidents — with weather warnings and day-over-day changes as planned future cards, deferred until their data sources exist).
Scrubber-anchored surface
A dashboard surface whose contents are read as of the scrubber's viewDateTime — it moves with time-travel, so cycling the scrubber to a past or future moment shows what was (or will be) true then. Members today: the Noticeboard clash list, the Noticeboard delivery list, and the incident-list panel body. The defining test: the thing has a *position or extent in time* — a planned Active period, a moment of conflict, a report date — so "as of when?" is a meaningful question. Decided 2026-06-04 (grill session). _Avoid_: time-filtered (overloaded — every panel filters by something), historical view (implies past-only; the scrubber runs forward too).
Now-anchored surface
A dashboard surface whose contents are defined by liveness / workflow status, not by a position in time — outstanding work that is "now and ongoing" until someone acts on it. It is immune to the scrubber: cycling to a past or future moment never changes it. Members today: the active-alert list and count, and the open-incident count badge (draft / submitted / in_review, all-time). Synonym: *live backlog*.
NotificationRecipientState
The per-user state row for one user's view of one Notification. Carries per-user lifecycle timestamps. Unique on (notification_id, user_id).
Audience (notification dispatch)
The resolved set of user_ids a notification was fired to. Materialised at fire-time into NotificationRecipientState rows; not backfilled when memberships change later.
Dispatcher (per-kind helper) — historical, V1 only
A Python helper function — one per notification kind — that knew how to compute the audience for *that* event and called the generic notifications.dispatch(content, recipient_user_ids=...). Lived in backend/app/services/notifications/dispatchers/<kind>.py (the directory no longer exists).
DomainEvent
A typed, structured fact emitted by a domain service after a state change has committed (e.g. IncidentSubmitted, ReportReviewRequested, WeatherThresholdCrossed). Carries a small payload (IDs + key fields), a site_id, and an actor. Pure data — no behaviour, no DB session. Emitted into the EventBus; multiple subscribers may consume.
EventBus
The transactional-outbox pipeline DomainEvents flow through — *not* in-process pub/sub, which the 2026-05-23 ADR explicitly rejected (at-most-once delivery for events whose misses are a safety issue). Four legs, all in backend/app/services/events/:
Event — one word, three unrelated systems
event names three systems in this codebase. They are separate on purpose: each passes the deletion test on its own, and merging a pair has been proposed — and rejected — more than once.
RulesEngine
The bus subscriber that evaluates per-site NotificationRule rows against incoming DomainEvents and calls notifications.dispatch() for matching rules. Replaces the per-kind dispatcher helpers as those are migrated.
NotificationRule
A per-site row authored by a coordinator/admin that says "when DomainEvent X fires AND payload matches conditions Y, then fire effect Z". Effects today are dispatch_notification (FYI inbox item) and create_task (see Task below). The shape: - trigger: an event kind enum (e.g. weather_threshold_crossed, incident_submitted, report_review_requested). - conditions: a recursive boolean predicate tree (all / any / not + leaf clauses with field, op, val). Mirrors the existing clash-rule predicate composition (see Clash engine seams). - audience: set algebra (union / intersection / coalesce) over *audience kinds*: role: hse, capability, contractor_members_set: $payload.contractor_id, payload_user: $payload.reporter_id, payload_user_list, specific_users: [...], site_duty_owner. Resolved at fire time to a set[UUID]. - effect: dispatch_notification (Notification + per-user state rows) and/or create_task (Task + TaskAssignment rows).
Task
A *pending action* assigned to one or more users, distinct from Notification (which is informational). A Task's shared lifecycle is stamp columns, not a status enum (TaskLifecycle.STAMPS: resolved_at / cancelled_at / expired_at; open = no stamp) — resolving by any assignee clears it for the rest. Has an action_kind (TaskActionKind: approve_incident, provide_incident_detail), a target entity reference, and an optional due_at. Surfaced via the My Tasks panel (né *My Work*, renamed 2026-07-17, #1257); not shown in the bell dropdown (which is notifications-only).
My Tasks
The dock panel listing *my open Tasks* — the single task inbox (dock id tasks, pencil-square icon, T keybind). Formerly My Work, a two-tab container that also carried a Close out tab (the bulk end-of-day Completion record capture, "close out today"). Decided 2026-07-13 (grill session). Amended 2026-07-17; shipped 2026-07-17 (#1257) (close-out re-scope, see docs/decisions/2026-07-17-close-out-two-verbs.md): the *Close out* tab is deleted — bulk completion moves to the site browser's multi-select — and the now single-tab panel un-wraps and renames to My Tasks (prefs v15 migration; a persisted-open my-work occupant migrates to tasks on load).
Channel
A delivery transport for a Notification or Task. Channel is an explicit axis: each rule's effect declares which channel(s) to use, and a *channel adapter* handles delivery. Three adapters are registered (services/notifications/channel_adapters/): in_app (WebSocket dispatch, in_app.py), email (email.py — enqueues EmailOutbox rows) and web_push (web_push.py). There is no SMS adapter; an unimplemented channel named by a rule fails cleanly at fire time with a log line, never a crash. _Avoid_: medium, transport, route (overloaded).
TaskAssignment
The per-user row for one user's view of one Task. Carries acknowledged_at (saw it) and lets the engine cancel/recreate assignments as audiences change. Unique on (task_id, user_id). Distinct from NotificationRecipientState (which carries *read* state, not action state — Task resolution lives on the Task row, not here). _Avoid_: task_user, task_inbox_entry.
Read state
NotificationRecipientState.read_at is the single per-user lifecycle field. NULL means unread; non-NULL means read. The bell badge count is COUNT(*) WHERE user_id = me AND read_at IS NULL AND (expires_at IS NULL OR expires_at > NOW()).
Click-through target
The Notification row carries structured target columns, not a URL: panel_hint (which panel to open), source_entity_type + source_entity_id (which entity to select). A small frontend NotificationClickRouterService switches on source_entity_type to call the appropriate panel-open + selection actions.
WebSocket audience: specific_users
One of the AudienceType literals in backend/app/core/websocket_events.py (AudienceType = Literal["owner_or_shared", "non_owner", "specific_users", "roles_or_users"]). The payload for specific_users is { user_ids: list[UUID] }; the _AUDIENCE_HANDLERS filter pipeline (in core/websocket_manager.py, registered via register_audience) matches the connection's user_id against the list. The notification subsystem composes over it.

Mentions

Mention
A directed reference to a Site member roster user, embedded inline in user-authored prose (e.g. a DiscussionComment (incident thread)|DiscussionComment body). A new Mention emits a DomainEvent consumed by the RulesEngine, firing a Notification of kind mention to the referenced user, pointing back at the surface where the mention was made. Cross-cutting — the same concept is used wherever a Mention-bearing surface accepts free-text input from users (today: incident and observation comments, via the shared discussion thread; near-term: Action and Inspection comment threads, and any narrative fields those grow). A single notification kind (mention) and a single seeded NotificationRule mean adding a new surface is a per-surface plumbing change; the engine layer is reused unchanged.
Mention pool
The set of users eligible to appear when a user begins authoring a Mention. Decided 2026-05-25 (grill session): the entire verified Site member roster, unfiltered by draft visibility, role, or contractor. The mention's job is to *pull a user into a thread*; pre-filtering pre-empts that intent. A mention sent to a user who can't see the underlying entity still fires; on click-through, the existing graceful-empty pattern applies (panel opens, row absent — see Click-through target).
Mention-bearing surface
A persisted text field that accepts Mentions. First landing: DiscussionComment.body on the incident thread; observation comments followed via the shared discussion thread. Near-term: Action and Inspection comment threads (and any narrative fields those grow). Adding a new mention-bearing surface plumbs the parser + emit-event into the surface's write path; the notification kind, audience resolver, seeded rule, and user pref row are reused unchanged.

Presence and identity

Site member roster
The set of verified SiteMembership rows for a site — the answer to "who's on this team?". Pending (unverified) members are excluded; they live in the admin/coordinator review surface, not in operational displays. The roster is the single source of truth for per-user identity attributes (display_name, role, contractor_id) consumed across the dashboard. _Avoid_: site users (overloaded with auth/identity), team list.
Presence
The per-session liveness overlay on top of the Site member roster. Tracks which roster members currently hold an active session in the site room (site:{site_id}), whether they're editing an entity, and where their viewport is centred. A user is "online" iff they appear in the presence list; "offline" iff they're in the roster but not in the presence list. Presence carries *status*, never *identity* — name, role, and contractor are read from the roster.
Role badge
The visual indicator of a user's SiteRole. Appears as a small corner overlay on presence bobbles (online and offline alike) and as a labelled chip in the Presence dropdown rows. Combines an icon (from heroicons) with a short label — nine roles, each an (icon, shortLabel, priority) entry in the ROLE_REGISTRY frontend constant (types/role-registry.ts; the once-planned per-role tint axis is gone — every entry shares the neutral background, identity rides icon + label). Roles not present in the registry (e.g. a new backend enum value not yet known to the frontend) render unbadged — no guessed fallback — and sort to the bottom of their section. The current user's own toolbar bobble is deliberately unbadged; the dropdown row representing them carries the full chip. _Avoid_: role icon, role tag, role chip (each describes a sub-part, not the atom).
Presence dropdown
The hover popover anchored beneath the toolbar presence bobbles. Two sections — Online (active sessions) and Offline (roster members not currently connected) — each sorted by role priority (admin → site_manager → coordinator → hse → member → commercial → contractor_hse → viewer → client, the registry's priority order) then alphabetically by display name. Includes the current user with a "(you)" annotation, in the Online section. Scrollable when content overflows a capped height; section headers stay sticky during scroll so the bucket label is always visible. Offline rows render at reduced opacity but keep contractor colour, role badge, and name intact — identity stays readable, only liveness recedes. Empty section headers are hidden. Non-interactive in V1 (no per-row click behaviour); the on-map viewport-cursor feature remains the affordance for "where is X looking right now?".

Row capabilities — predictive permission flags

Row capability
A per-user boolean on a casework record's read surface (can_edit, can_resolve, can_contest, …) predicting whether the server would allow that verb for that user on that record, right now. It mirrors the service's own gate — band, record state, and the user's relation to the record — and never enforces anything itself: enforcement stays at the route/service gate, and a capability that under-reports its gate hides the verb from exactly the persona the rule exists for (the entry-69 lesson). The casework kinds (the WorkGroup kinds — blocker, incident, observation, snag, defect — plus the standalone action) serve the same shape of flags.
Refusal (row capability)
The refusal a flag raises when it does not hold (Refusal.status_code defaults to 403), carried as spec-row data beside the arms: a code, a sentence in site language, a status, and optional variants — a state-keyed copy override, so *why* a window is not editable ("this record was rejected" vs "that is not your firm's record") stays two sentences under one flag. A variant states copy only; it can neither admit nor refuse. _Avoid_: error message (the copy alone), guard (the call site).
Deferred gate
A record-state atom a flag carries for prediction but its refusal is deliberately blind to, because a sibling guard at the call site refuses that world in its own words and its own status code — an archived record is 409 blocker_archived, the close path's lifecycle is 409 blocker_not_resolvable, never a 403 about authority. Declared per refusal and checked at spec load, so the one place where the flag is *narrower* than the guard it predicts is written down rather than discovered. The measured invariant: flag ⟺ refusal admits ∧ every deferred atom holds. _Avoid_: precondition (that is the sibling guard's own subject).
Scope-like vs voice-like relation
The two kinds of user-to-record relation an arm can require. A scope-like relation bounds *reach* — "within the contractors you may act on" (own_scope). A voice-like relation names *who may speak in a role the record creates* — the declarer, a member of the record's own firm, a member of the accused firm (may_contest). The distinction carries the superadmin rule: a superadmin holds every band and passes every scope-like relation, but fails every voice-like relation — they belong to no firm, so there is no accused firm to speak for (can_contest=False on a superadmin is correct behaviour, not a gap). Decided 2026-08-09 (grill session): this distinction is declared per relation, and the superadmin answer is *derived* from it, never hand-authored per kind.
Disclosure
**Who may *see* a record at all** — the row-level read grant, a first-class concept distinct from a Row capability: a capability predicts a *verb* for a reader already looking at the record; a disclosure decides whether the record exists for them (a reader outside it gets a 404, never a 403 — disclosure does not leak existence). A kind enrols its read grant once in app/services/workgroup/disclosure.py — its spec, its read flag, its context and viewer builders — and that single declaration serves all three consumers: the row predicate, the band question, and the list clause. There is no per-kind _can_see / _visibility_clause pair to keep in step, because there is no second author; the generative twin-agreement harness (tests/support/disclosure_twins.py) asserts per persona that the Python predicate and the composed SQL filter name the same row ids. Enrolment is discovered, not listed (#2802): disclosure.discover_capability_modules() reads the app/services tree for the enrollers, so there is no hand-maintained module list a kind can be missing from, and the discovery census (backend/tests/test_disclosure_enrolment_census.py over tests/support/enrolment_census.py) is the forcing function — it fails both the enroller the harness cannot see and the sql_rendered read flag that never enrols at all, and its two deliberate absences are declared with their grounds rather than stepped over. The composition law lives on the enrolment: list_filter returns None for "no filter" (the reader's band reads everything) and an explicit false() for "renders nothing" — conflating the two is whole-table disclosure. Eight kinds are enrolled: blocker, incident, observation, snag, defect, inspection, permit, alert. Decided 2026-08-16 (epic #2645 story A; docs/decisions/2026-08-16-row-disclosure-seam.md). _Avoid_: visibility clause / _can_see (the retired hand-written twins); row capability (verb prediction, not read grant); RBAC (the band grant is one atom of an arm, not the rule).

Event strips — per-kind append-only timelines

Event strip
One casework kind's append-only timeline — a per-kind event table (incident_events, observation_events, snag_events, blocker_events, …) whose rows all carry the same five facts: the parent detail's FK, the denormalised site_id, the acting user, a per-kind event_type enum, and a JSONB metadata payload. The tables stay per-kind (they carry per-kind enums); what is shared is the *semantics*, owned by the EventStrip substrate (app/services/workgroup/kind.py): how a row is appended and the total order it is read back in — instant first, then a deterministic tiebreak (seq where the strip has one, else id), so equal instants can never reorder between the timeline route and a serialiser's in-hand sort. Binding a strip is two facts (row model + FK column name); a kind cannot hold a private opinion about the ordering or the append. Two tables bind for their order alone (the HSE register's statutory hse_report_events and the stock_events ledger — neither carries the five facts, so neither may claim the append semantics). Eight strips are enrolled (epic #2645 story C, #2657–#2659). _Avoid_: audit trail (cross-entity, user-action-shaped, not a per-record timeline); DomainEvent (the durable delivery pipeline — a different "event"); activity feed (a rendering, not the substrate).

Casework substrate — reading a population, reading one record, ending a write

WorkGroupPage
One primitive for reading a WorkGroup kind's population — the read-side twin of the kind spec. Every list, queue and lookup over a kind otherwise restates the same five facts, and each restatement fails silently in its own direction: the WorkGroup→detail join; the group_type kind gate (omit it and the read is correct only while no other kind's detail table happens to answer its refinement); the site scope (omit the empty-scope short-circuit and you ask for site_id IN (), or — worse, if the scope was simply dropped — for every site's rows); the soft-delete default; and the Disclosure clause (hand-composing it means deciding the "band holder ⇒ no filter" half yourself, and reading a rendered-nothing clause as "no filter" discloses the whole table). A page owns all five, so a caller states only what is genuinely its own — which sites, which reader, and a refine callable that narrows the composed Select. Binding a page is two facts: the kind spec and the group_type. Five kinds bind one — blocker, incident, observation, snag, defect (app/services/workgroup/page.py). What a page deliberately does not own: ordering (the caller's — a queue ages by mint, a list by declaration — though page() *requires* one, because a paginated read without ORDER BY returns an arbitrary slice); serialization (it returns (WorkGroup, detail, *extras) rows, never wire shapes); and authorization (the scope arrives already reduced, the disclosure clause already resolved). Serialization is the Read shape seam's, which the page meets at the tuple and shares no opinion with. soft_deletes is derived from the detail model's column rather than declared, so a kind that grows a deleted_at gets the filter on its next read; the kinds without one are defect and alert. Decided 2026-08-20 (epic #2792). Entities are not the only shape a population is read in. A whole-site fold — the slip read's record context, its citation counts, the diary's snag tally — wants a handful of *columns* and must not hydrate an ORM row per record (BlockerDetail.evidence is lazy="selectin": the entity drags every evidence row on the site behind nine scalars). Those reads are the same population under the same five facts, so project() answers them in columns where rows() answers in entities. Its disclosure keyword is required, with two legitimate answers and no default: a reader's rendered clause, or an explicit None meaning *this fold runs behind its own gate*. Five folds took that decision silently before 2026-08-26 (#3359) — and had already decided the soft-delete arm three different ways between them. _Avoid_: query helper (it is the authority, not a convenience); repository (no write side); Record view (one record, not a population).
Record view
One record, as one reader sees it — the reader-scoped single-record load, as a value. A record surface is expensive *on purpose* (a clamping-set load, a per-basis-kind existence check, provenance queries, an indexed timeline read, a cross-record cause lookup, a declaration-closure walk); a list read carries none of it. That asymmetry is the deliberate part — *the right price on a record surface and the wrong one on a list* — so the two are two seams, not one parameterised one. What a record view removes is the hand-wiring: the loads that used to sit as a dozen sequential awaits inside a serialize_full method, where every new disclosure line was a same-method edit competing with the other eleven and the only instrument that could count the round trips was an HTTP request. As one loader it gives the read one measurable price (the statement count is pinned by a test, so a lazy re-load added later goes red), threads one clamping set through the whole disclosure by construction, and leaves the serializer pure — handed a RecordView, touching no session, so what the record *says* is testable without a database and what it *costs* is testable without a route. Four kinds have one (blocker, incident, observation and, since 2026-08-24 (#3102), snag — each services/<kind>/record_view.py); the snag's loader awaits nothing, which is a fact about how cheap that kind's derivations are and not a reason for it to read through a different door. _Avoid_: DTO (it is a load result, not a wire shape); serializer (the pure consumer of one); WorkGroupPage (a population, not a record).
Read shape
How a kind turns rows into its wire shapes, declared as a value — and the one implementation of *load once, then flatten* that every casework read rides (app/services/workgroup/read.py). Two shapes per kind, both declared in its services/<kind>/record_view.py: a RecordShape (the Record view loader plus the pure flattener) and a SummaryShape (the page loader, the pure row builder, and the kind's predictive-permission function). They are deliberately NOT on the kind spec — a kind's read module imports its kind_spec leaf, so a spec field pointing back at the loader would close an import cycle. What the seam owns is the two laws the four kinds were restating: the record's *load-then-flatten*, and the page's *resolve everything for the WHOLE page, then build rows without awaiting* — including the reader's membership per site the page spans, which is one batched query through core/rbac.get_site_memberships. That last part is why the fold was not cosmetic: incident's copy resolved the membership inside the row loop, a statement per distinct site, which is the per-row load the observation page had already removed (#2795) surviving on a sibling because nothing held the copies to one law. user=None means no permission block at all — the audience-less shape a broadcast or an anonymous submission answers with, never a defaulted-open one — and that is the law the cross-kind conformance suite drives through all four kinds. Decided 2026-08-24 (#3102, epic #3051). _Avoid_: serializer (the pure per-kind function a shape names, not the seam); WorkGroupPage (which rows, not what they say); presenter.
CaseworkCommit
The one write epilogue every casework verb ends with — flush → settle → audit → commit → drain → announce (app/services/workgroup/casework_commit.py). The sequence used to be hand-copied at ~50 call sites in two incompatible dialects: incident and observation wrote the audit row and blocker, action and inspection wrote none at all — three registers with an Event strip and no compliance trail; incident settled before the commit and observation sometimes after; incident drained before its broadcast and observation after it. None of that was a decision about those kinds, it was the order the code was written in. The order is the module's invariant, and the per-verb variance is the argument list. Why this order: flush so constraints fire here and FK-dependent rows have ids; settle (refresh) so the audit snapshot records what the row *is* after the write and the announcement's serialiser reads loaded attributes; audit staged into the same transaction as the write it describes (a trail that could commit separately from its subject is not a trail); commit through commit_point, never a bare session.commit(), so inside a door's unit_of_work it degrades to a flush; then drain the effects queued inside the transaction, then announce. The two records are not alternatives: the Event strip is the domain's own timeline, phrased for the people doing the work; the audit log is the compliance trail, one uniform shape across the estate, carrying actor, contractor, a round-trippable snapshot and a revision hash. A kind needs BOTH, everywhere. Decided 2026-08-20 (epic #2792 ruling R2). The enrolled set grows: the snag joined 2026-08-24 (#3099) — it was the last WorkGroup casework kind writing a strip and no trail, and its self-certified close-out is what releases a Completion record's "N snags open" caveat. Which kinds are under the seam is answered by CASEWORK_SERVICES (backend/tests/casework_epilogue_registry.py), never by a count in prose. _Avoid_: "the save method" (it is the epilogue, not the write); unit of work (the transaction door it calls, one layer down); post-commit hook (drain and announce are two of six steps, not the whole).

Partial update — absent, null, cleared

Patch
The absent-vs-null door — the one authority for the question every partial update asks, per field: *skip it, clear it, or set it?* (app/core/patch.py). Three wire shapes carry that decision — the field is absent, the field is null, or a clear_<field>: true flag stands beside it — and before 2026-08-24 the estate read them in four incompatible dialects at once: 13 clear_* request flags, 80 model_fields_set / exclude_unset probes across 45 modules, and a private UNSET sentinel in the HSE register's writes. Nothing about any one site looked wrong on its own; the incoherence lived *between* them, and api/routes/incidents/incidents_records.py spoke two dialects in one call — position_wgs84: null was ignored while persons: null emptied the involved-person set, so a reader had to know which argument they were looking at before they could predict the outcome.

Incident discussion

DiscussionComment (incident thread)
A single user-authored post on a discussion thread. The model is the polymorphic discussion substrate (DiscussionComment / DiscussionCommentRevision / DiscussionCommentAttachment, models/_discussion.py, table discussion_comments), keyed by (subject_kind, subject_id) — the incident is one consumer (subject_kind="incident"). Carries a body, an author, timestamps, and optional attachments. Allowed in every incident workflow status (draft / submitted / in_review / approved / rejected) and frozen only when WorkGroup.status = archived. Body is editable by the author at any time (each edit writes a DiscussionCommentRevision row); whole-comment delete is allowed by the author (soft-tombstone) or by holders of incident_comment_moderate (hse + admin) for moderation, with a recorded reason. The body is a Mention-bearing surface — @-tokens in the body fire Mention notifications on POST/PATCH per the diff-based emission rule. _Avoid_: note, reply, message, post, remark; IncidentComment (no such model — the thread rides the shared substrate).
DiscussionCommentAttachment
A file attached to a DiscussionComment. Stored in a table separate from IncidentEvidence for forensic separation — the formal H&S record (initial evidence locked at submission) must remain distinguishable at the data layer from discussion artefacts uploaded later. Same 25 MB cap and same upload helper as IncidentEvidence; soft-deleted via removed_at rather than hard-deleted, with a faded placeholder visible in the UI. _Avoid_: comment file, secondary evidence.
DiscussionCommentRevision
An immutable snapshot of a DiscussionComment's body taken when the author edits it. The revision row stores the *pre-edit* body plus replaced_at + replaced_by_user_id. Drives the "(edited)" indicator + clickable diff in the comment UI. Comments are never hard-deleted; revisions are never edited. _Avoid_: version, history entry, edit record.
Relationships
- An incident's thread is the set of DiscussionComments with (subject_kind="incident", subject_id=<incident>) — a service-layer relation, not an FK on IncidentDetail. - A DiscussionComment has zero or more attachments and revisions. - A new comment writes an IncidentEvent (event_type=commented); a delete writes IncidentEvent(event_type=comment_deleted). Self-edits do *not* write timeline events — the revision row carries that history.
Distinguishing from sibling concepts
- vs IncidentEvent (backend/app/models/_incidents.py): events are *immutable workflow markers* on a per-incident audit log (submitted, approved, …). Comments are mutable discussion content. The two *intersect* — a posted comment writes an event for timeline coherence — but they answer different questions. - vs IncidentEvidence (same module): evidence is the *formal H&S record* uploaded at the time of incident creation, locked when the incident is submitted. Comment attachments are *discussion artefacts*, separable by table at the audit layer. - vs Notification: a notification is a per-user inbox item that *may be triggered by* a new comment (the dispatcher pings thread-participants + reporter + all hse/admin). The comment is the content; the notification is the surfacing.
Permissions
Two new Permission enum members: - incident_comment — granted to member / coordinator / hse / site_manager / admin. The reporter can comment on their own incident regardless of role (matches the existing reporter-bypass pattern for can_edit / can_submit / can_delete in compute_incident_permissions). - incident_comment_moderate — granted to hse + admin only. Soft-deletes (tombstones) any comment with a recorded reason; writes IncidentEvent(comment_deleted).

Incident investigation

Investigation risk axes
Nullable likelihood × consequence (1–5 each) on an incident, rating recurrence risk to size the investigation — deliberately the same 5×5 arithmetic and derived-band vocabulary as Observation risk rating, not a second matrix grammar (Frida's 3×3 was rejected). Distinct from the incident's flat severity, which stays the *outcome* classification. _Avoid_: severity (the outcome axis); risk matrix unqualified (say which instrument).
Investigation level
The band (low/medium/high/critical) derived from the Investigation risk axes — how deep the investigation goes, not how bad the outcome was. _Avoid_: severity band (the observation/incident outcome vocabulary).
Incident cause
A typed causal factor on an incident: free-text cause + a type from a per-site seeded registry (the Observation category pattern), optionally pinned to a Barrier layer and/or a Timeline step. Repeatable — supersedes the single free-text root_cause, which freezes read-only. _Avoid_: root cause (the frozen legacy field).
Barrier layer
The Swiss-Cheese defence a cause breached — organisation | supervision | conditions | unsafe_acts | technical_failure. A column on Incident cause; the column is the app's Swiss-Cheese model. _Avoid_: layer unqualified; slice.
Timeline step
An ordered Chain-of-Events step (label + narrative) on an incident investigation; causes pin to steps; the terminal step is the incident itself by convention. V1 edits as an ordered list — the drawn-timeline canvas is a later rendering upgrade, not a schema change. _Avoid_: event (an IncidentEvent is the audit-timeline row — a different thing); step of the workflow (this is causal narrative, not FSM).
Investigator
The nullable assigned-investigator slot on an incident, set by HSE (typically at review start). "Classifier" remains a permission (classification amendment), never a slot. _Avoid_: reviewer (the band, not the assignment).
Incident closure (derived)
An incident is *closed* when its workflow is terminal (approved/rejected) and every child Action is terminal (accepted/cancelled) — computed at read time, never stored. Frida's "Implementation" phase = approved with open actions, which falls out for free. _Avoid_: case-closed flag (a stored bit was rejected — it can contradict the derived truth).

Observation

Observation
A Safety Observation Report: an on-site safety sighting — an unsafe act, unsafe condition, or safe behaviour (ObservationType) — raised by anyone on site, including anonymously (in-app or via a QR Observation capture link), and gated through HSE Observation triage before it reaches the wider site. Modelled as a WorkGroup(group_type= observation) with a 1:1 ObservationDetail extension and surfaced as the fifth Auxiliary pin kind — the heroicons eye glyph, tinted by the derived Observation risk rating band once published (pending pins render dimmed on the HSE-only view). It shares Incident's *shape* — a positioned pin with Position provenance, involved entities, free-text persons, evidence, a Mention-bearing discussion thread, an event timeline — but its own lifecycle: submitted → triaged → published-or-discarded, with map visibility derived from status + band vs the site's Observation visibility threshold (accepted safe_behaviour rows are threshold-exempt), never encoded in the status. Submission emails site hse users immediately; crossing into visibility at triage emails coordinators. Accepted observations feed the report exactly once via the Report observation queue (a section plus SOR summary stats); an open published Observation is a Now-anchored surface item — never a Noticeboard row. Decided 2026-06-11 (grill session), superseding the 2026-06-09 instant-publish identity: see docs/decisions/2026-06-11-observation-triage-rework.md. _Avoid_: incident (the *something happened* record; Observation is the *something was seen* record); the v1 sense "calm instant-publish operational flag" (superseded); inspection / action (sibling surfaces — and note the boundary: an inspection Item result is deliberately never promoted to an Observation); note, sighting, finding.
ObservationStatus
The Observation lifecycle enum: pending_triage (the default at creation) → open (HSE accepted) or discarded (HSE judged not actionable; restorable), with open ⇄ resolved reopenable as before. Deliberately does not encode visibility — who sees an observation is derived from status plus Observation risk rating band vs the Observation visibility threshold, so re-rating never churns status. Distinct from soft-deletion, which remains a separate reversible act reserved for spam/test data, not a triage verdict. _Avoid_: published / logged as status values (visibility is derived); state (overloaded); incident status (a different enum).
Observation triage
The HSE act (hse / site_manager / admin) of reviewing a pending_triage Observation: accept or discard, rate or re-rate the Observation risk rating (a rating is required to accept an unsafe row — amended 2026-09-09), set or correct the ObservationType and Observation category, and reposition inaccurate pins by drag or Position rectification (routine for no-GPS anonymous submissions, which arrive on an Inferred location, and for any Rough position). Stamped triaged_by_user_id/triaged_at; performed from the triage queue in the Health & Safety panel. Coordinators never triage — the gate exists so HSEs sift observations before coordinators see them. _Avoid_: review / approve / reject (Incident's workflow vocabulary); moderation (the comment-thread term).
Observation risk rating
The 5×5 risk matrix carried by unsafe observations: likelihood (1–5) × consequence (1–5) → score (1–25) → a derived band low/medium/high/critical (fixed boundaries 1–4 / 5–9 / 10–15 / 16–25). The submitter may rate at creation via a colour-zoned tap-grid that sits in a fold collapsed by default on every authoring surface — *Rate the risk (optional)* / *HSE will rate it if you don't.* (absent for safe_behaviour); HSE rates or re-rates at triage and the band follows — Accept is gated on a rating for unsafe rows (*Rate the risk to accept*), Decline never is. A row that arrived unrated is an ordinary pending row marked Unrated (muted text, never a pill) and sorts by recency beneath the submitter-rated rows; the initial submitter rating is preserved for calibration and is NULL when they did not rate (amended 2026-09-09, map #3779; ADR 2026-06-11-observation-triage-rework D3; spec docs/plans/2026-09-09-observation-rating-fold-and-position-rectify-spec.md). A safe_behaviour is rating-less, map-less and action-less: it carries no corrective Action, because there is nothing to correct (amended 2026-08-31, #3617; ADR 2026-06-11-observation-triage-rework D4). Triage refuses an action block on a row accepted as a safe behaviour by type (observation_action_not_for_safe_behaviour) and does not offer the step at all — both reading the type the row is being *accepted as*, so reclassifying in the same act moves the step with it. The band — not the raw score — is what pins, filters, queue sort, and the Observation visibility threshold speak. _Avoid_: severity unqualified (Incident's flat enum — same band names, a different instrument); priority, urgency.
Observation visibility threshold
The per-site band (Site.observation_severity_threshold, default medium, tunable by hse / site_manager / admin) gating coordinator/member/viewer map visibility of rated (unsafe) observations: visible ⇔ status ∈ {open, resolved} and band ≥ threshold. safe_behaviour rows carry no band and are threshold-exempt — visible to every role once accepted (decided 2026-07-13, grill session; before that their NULL score never qualified, so positives were HSE-only). hse/admin see everything including the pending queue; a signed-in reporter always sees their own. It is also the default for the Report observation threshold, but no longer binds it — the one knob that served both surfaces was split when the report needed to reach below the map (decided 2026-06-12, see docs/decisions/2026-06-12-observation-report-threshold-split.md). _Avoid_: severity threshold unqualified; "report inclusion" (that is now the separate Report observation threshold).
Report observation threshold
The per-report band floor governing which accepted observations appear in the SOR report section — a separate knob from the Observation visibility threshold, persisted in the report section's form_data, defaulting to the site threshold (so a fresh report mirrors the coordinator map). Lowerable below the site threshold by anyone who can build the report (coordinator and above): below-threshold accepted observations are noise-filtered, not confidential, so an HSE-reviewed SOR may deliberately be more complete than the live map. It moves the band floor only — pending_triage/discarded rows never reach the report at any floor. safe_behaviour rows carry no band and are governed instead by a per-report *include safe behaviours* toggle (stats-only otherwise). Manual per-row check/uncheck (force_included/force_excluded) survives floor changes — the floor is a default line, not a reset. _Avoid_: visibility threshold (that one gates the map); a report-mode "severity filter".
Report observation queue
The exactly-once feed of accepted observations into reports. An Observation joins the queue when Observation triage accepts it and is consumed by the first exported report whose observations section offered it — printed, force-excluded, or left unchecked below the Report observation threshold floor alike: every offered row was surfaced to the report builder, so an exclusion is a recorded decision, not a crack. Draft reports never consume (abandoning a wizard returns nothing to limbo); each consumed observation records which report carried it. This replaced the observed-on-the-report-day window, which could never match — reports target tomorrow while sightings live in the past, so pre-queue observations never appeared in any report once triage lagged the sighting by a day (decided 2026-07-13, grill session). _Avoid_: report window (the temporal entity window, a different axis); backlog (the pending-triage pile — that is upstream of this queue).
Still-open observations
The report's non-consuming register of accepted unsafe observations still open that an earlier exported report first carried — the sibling group beside the Report observation queue, inside the one Health, Safety & Environment section. Disjoint from the queue by construction (the queue offers reported_at IS NULL, this register lists IS NOT NULL), so a row is offered for stamping or listed as still open, never both. Each row is a compact line — ref, band, description, location, corrective-action owner/due, first carried (a backfill sentinel prints "before the queue"), days open counted from Observation triage acceptance — oldest-open first, identical in the report panel, the PDF and the DOCX. It never stamps anything and offers no checkboxes: a report may drop the whole group through the section's include_still_open toggle, never one open hazard, and a row exits only by becoming resolved (reopening re-enters it). Capped at 200 rows with an explicit overflow line; the stats block stays batch-scoped and states still_open_total beside it (decided 2026-08-21, grill session — see #2984 and the amendment on docs/decisions/2026-07-13-observation-report-queue.md). _Avoid_: "carried" / "carry-forward" (the report table carryforward mechanism is an unrelated copy of prose rows, and it skips itself once a section has user content); backlog (the pending-triage pile).
ObservationType
Repurposed 2026-06-11 to the SOR three-way: exactly unsafe_act / unsafe_condition / safe_behaviour, now required at creation (three big buttons on every form, including the anonymous one). The v1 hazard-domain values (housekeeping, obstruction, …) moved to Observation category. safe_behaviour — the positive observation in customer speech and report labels — carries no risk rating; once accepted at triage it is visible to every role (a green eye pin, threshold-exempt — amended 2026-07-13, it previously never reached the coordinator map) and feeds the report's positive-observation ratio. _Avoid_: category (that is the hazard domain, a separate field); the v1 value set; kind, nature.
Observation category
The hazard domain of an Observation — a per-site, DB-seeded registry, not an enum (seeded: working_at_height, ppe, scaffolding, electrical, manual_handling, housekeeping, plant_and_equipment, environmental, security, access_egress, other; client-tunable without a migration). Single-valued and nullable at submission — anonymous reporters may not know the right bucket — and set or corrected at Observation triage. _Avoid_: bare "category" unqualified (collides with Clash category); tag, label.
Anonymous observation
An Observation whose reporter is truly unrecorded — reporter_user_id NULL and the audit identity suppressed — whether it arrived via the QR form or a signed-in user choosing *submit anonymously*. No sealed/hidden identity column exists, by design: the anonymity promise is real. Accepted consequences: no see-own/edit-own powers and no notification backflow for the reporter. A non-identifying submitted_via (app / app_anonymous / qr) is kept for triage context, plus an optional volunteered contact field. _Avoid_: pseudonymous (implies a recorded identity); unattributed.
Site boundary
A nullable site-level polygon (authored with the map drawing tools by the coordinator/hse/admin band) marking the operational perimeter — config, not map content: deliberately outside the Reference feature pipeline so import/versioning/bulk-delete never touch it. When unset, the active site map's bounds serve as an explicit degraded fallback. First consumer is Geofenced capture entry; the concept is generic — any future "is this on site?" question (Blocker whole-site scope, stock reasoning) may reuse it. Decided 2026-07-06 (grill session). _Avoid_: geofence (the *check* against the boundary, not the polygon); perimeter, site extent (the raster Site-map capture bounds — a scan footprint, not this).
Geofenced capture entry
The tokenless alternative to the QR poster: one global /observe URL where the server resolves the reporter's GPS fix against Site boundarys (buffered by fix accuracy) and serves only the matched site's capture form — one match goes straight in, several show a picker of matched names only, zero hands off to the poster path. Near-miss site names are never returned: non-enumerability is preserved by physical presence rather than token possession. The fence gates resolution only — a served form's submission is never rejected on location; the server stores containment facts (in-boundary, distance, accuracy) as provenance with submitted_via='geofence', and spoofability is accepted with Observation triage as the firewall. Successful resolution mints a short-lived session token required by the write endpoints (an ephemeral Observation capture link analogue). Decided 2026-07-06 (grill session). See docs/decisions/2026-07-06-geofenced-anonymous-observation-entry.md. _Avoid_: geofenced observation (the observation is ordinary; the *entry* is geofenced); GPS gate (resolution-only, never a submit gate).

Corrective actions — the close-the-loop hub

Action
A corrective work item raised when something on site needs fixing, owned by one responsible company (a contractor, never a user, at creation) and tracked through a review-gated lifecycle to HSE sign-off. A standalone entity — not a WorkGroup extension, not a Task — carrying a description, due date, per-site serial number, optional Action priority, and an optional Parent reference. Members may only route within their own ∪ supervised companies; coordinator/hse/site_manager/admin route to any (the sketch's COMPANY SCOPED vs ALL). Map presence is a badge on the parent's pin, not an own pin. _Avoid_: task (the per-user ping that *points at* an Action); corrective action triple (the frozen ObservationDetail seed fields); action item, todo.
Action lifecycle
assigned → pending_review → accepted, with cancel reachable from either live state. Creation always names a responsible company, so a new Action lands in assigned; Action close-out moves it to pending_review; HSE accept terminates; reject is an audited *transition* back to assigned (reason required, count kept) — the rework loop is not a state. cancelled is the terminal escape (an extension beyond the source sketch). Overdue is derived from the due date, never a state. _Avoid_: open/resolved (Task vocabulary); draft (no draft state exists).
Action close-out
The responsible company's evidence-backed claim of completion: any member of the responsible contractor (or HSE/admin on-behalf) submits a solution narrative plus photo evidence — hard-required; stamped with date-completed and who closed out. Enters Action review. _Avoid_: resolve (Task verb); complete (Inspection vocabulary).
Action review
The HSE sign-off gate on a closed-out Action: accept (terminal) or reject back to assigned with a required reason. The review band is hse + admin + site_manager, mirroring the incident review split. _Avoid_: triage (Observation vocabulary); approve (the incident gate's verb).
Action priority
Optional low/medium/high, chosen by HSE — required when the Action is spawned by a failed inspection item — and the input to due-date defaults. _Avoid_: severity (an outcome scale); band (the observation instrument).
Parent reference
The optional polymorphic link from an Action to the finding it remediates — an Observation, an incident, or an inspection Item result. Optional by design (ad-hoc actions are legal) and one-to-many (a parent may carry many Actions; the observation triage prompt raises one by default, prefilled and skippable). _Avoid_: target entity (Task vocabulary).

Inspections

Inspection
A templated safety audit run by an HSE inspector against an Auditee company: checklist items answered with comments and photos, a validation-gated submit ("submit available iff valid"), and a corrective Action offered per failed item at completion (the offer is a per-row prefill in the review step — several fails may be merged onto one Action, and the raise happens inside the submit transaction). Two-state lifecycle: in_progress → completed — submit is terminal; the inspector *is* the authority, so there is no separate approval step. Completed inspections are read-only, site-visible, scored, and PDF-exportable. _Avoid_: audit (colloquial); survey; observation (the sibling ad-hoc surface — inspection items never become Observations).
Inspection template
The admin-authored checklist an inspection runs against: named, site-scoped, and versioned — editing mints a new immutable version (sections + items + pass threshold) and a run references the exact version it used, so history never mutates. Per item: question, section, comment required/optional, evidence-required (⇒ photo). _Avoid_: inspection type (Frida's column name for the same idea); form; checklist unqualified.
Item result
One answered checklist item on an inspection run: a three-way answer — pass ✓ / fail ✗ / N-A — plus comment and photo per the template item's flags. Deliberately inspection-internal: never promoted to Observation rows. A fail feeds the submit-time action confirmation step. _Avoid_: finding (unqualified); observation (the sibling entity).
Pass score
The completion-time score — passes over non-N/A items, judged against the template version's pass threshold — denormalised onto the completed inspection. _Avoid_: risk rating (the observation instrument).
Auditee
The company an inspection audits: a required reference to the site's contractor registry (a subcontractor or the principal contractor), never free text. Drives the responsible-company prefill of fail-spawned Actions and the completion-notification audience. _Avoid_: contractor name (the Frida free-text field this replaces).

Contractor discipline

Discipline
A contractor's trade classification — an unordered set of values from a fixed global vocabulary: civils, structure, envelope, mechanical, electrical, fire, building_services, fit_out, logistics, other. Multi-valued by domain necessity: an M&E firm is one firm with two trades, never two contractor rows (contractor identity is load-bearing for colour, clash predicates, oversight and audit). No primary value. building_services is fenced to the specialist services tail (lifts, BMS/controls, security and access, ICT/data, lightning protection) — never a home for plain mechanical, electrical or fire work, which have their own values. logistics is a *function* the diary must record (welfare, deliveries, cranage), not a trade. Site-specific packaging ("the civils team oversees the frame works here") is a contractor oversight edge, not a discipline — the tag is what the firm's own hands do. Drives Daily Site Diary trade-section routing only; all other consumers are deliberately deferred. Every contractor must end up with at least one; creates and edits can never reach zero. See docs/decisions/2026-08-09-contractor-discipline-classification.md. _Avoid_: MEP / M&E (a firm holding both mechanical and electrical, not a value); category (a clash-engine noun); trade tag (free-text connotation).

Work tracking — lines, dependencies, snags, lost time

Work line
A first-class, contractor-owned line of programme work — the anchor for Work dependency edges *and* for close-out. P6-mappable core (opaque nullable activity code, description, place band, planned window) so a programme import is a mapping exercise — that import now exists: lines may be born by Promotion from a staged Programme activity, carrying natural-key provenance (source_programme_id + activity code) that keeps them in Sync review|living sync; hand-authored lines are unchanged (all-NULL provenance, invisible to sync). Status not_started / in_progress / complete, moved by explicit start / close / reopen verbs (reopen returns a closed line to in_progress, clearing the close stamps), never by PATCH; one line = one contractor. Hand-authored (or promoted) by the coordinator band — permit import mints nothing. Tokens attach via a nullable FK: the contractor picks at placement, group-inherited, with a coordinator backstop. Closing a line is the load-bearing "can the next trade start?" verb — evidence-backed when the line is a dependency predecessor. Daily Completion records and the Planner's permit atom are unchanged. See docs/decisions/2026-07-17-work-line-first-class.md. _Avoid_: activity (the planner-sense reality atom and P6's own noun — a work line may *carry* an activity code, it isn't one); task (the notification-pipeline entity); work package (WBS vocabulary).
Work dependency
A planner-authored or programme-imported edge WorkLine → WorkLine — never tokens (tokens churn; the line endures). Carries a link type (FS|SS|FF|SF, mirroring P6; hand-authored edges default FS) and a signed lag (stored in hours verbatim, evaluated as round(/8) calendar days) — the #1205 generalisation, pulled by the programme-import epic (2026-07-21; FS-only was the original shipped shape). Same-contractor edges permitted; self-edges, duplicates (same pair and link type), cross-site edges and cycles rejected (DFS at insert); transitive redundancy tolerated. Edges are immutable (edit = remove + re-add); deleting a line confirm-and-cascades; linking an already-complete predecessor is legal (Born satisfied). The contract per link type (FS: "B must not start until A is complete") yields three derived, never-stored conditions: *violated*, *unverified start*, *satisfied* (kept as record; completion notifies the successor's contractor). Authoring rides the one shared work-line permission (coordinator band; contractors never); imported edges carry provenance columns and are maintained by Apply|sync apply, hand-authored ones are sync-invisible. See docs/decisions/2026-07-17-work-dependencies-and-violation-surfacing.md (+ the 2026-07-21 amendment in 2026-07-21-programme-revision-sync-and-edge-import.md). _Avoid_: token dependency (rejected shape); relationship (the staging row ProgrammeRelationship — an imported edge is its promoted consequence).
Dependency violation
The derived contradiction of a Work dependency — not a clash (clash stays geometric/emergent; a violation is *declared logic contradicted*). A dedicated graph-driven evaluator (on-write + sweep) persists violation episodes (own table; the edge stays stateless) that surface as a third noticeboard item kind above red — never ignorable, never suppressible; the only remedy is fixing the graph or the work. Two kinds, one episode shape: *violated* (predecessor-token ∩ successor-token schedule overlap while the predecessor line is unclosed — time-only, no geometry) and *unverified start* (B's window arrives, A unclosed, no live/future A-tokens — assume the worst). Both carry a one-click, pre-filled declare-Blocker handoff: the violation surfaces automatically, a human declares the blocker. The close-out duty ladder escalates FYI (edge authored) → nudge (last predecessor token ends unclosed) → violation (B arrives). Presentation: user copy says "delays" — "delay" replaces "violation" in *all* user copy (a *delay* is the planning concept: a declared dependency contradicted; internal/ schema/event names keep violation), and delays *create* Blockers — the blocker stays the sole timed-stoppage record, so a "Delays"-named stoppage surface is a Blockers surface. The pin is the hazard diamond; the noticeboard renders an uncollapsible emergency banner; map paint is place-centric (place-band glow, token-less). RuleSeverity is untouched. See docs/decisions/2026-07-17-work-dependencies-and-violation-surfacing.md. _Avoid_: clash (different machinery and severity vocabulary); mega-red (a prototype nickname, not shipped copy); blocker (a violation *suggests* one; only a human declares it).
Snag
A contractor's own-work defect caveat — work not constructed to spec, flagged by the team that built it while recording work completion (the "wee box" riding every record-work-completion gesture, daily or closing; there is no standalone raise button). A snag can never be raised against another trade: a cross-trade grievance is a Blocker. Substrate: a WorkGroup sibling (SnagDetail), anchored by a polymorphic token ref + work_line_id snapshotted at raise. Two stored states, open → closed; the *"complete · N snags open"* caveat (Tony's "98%, not 100%") is derived, never stored — the roll-up to 100% is the join emptying. Close is a per-snag human tick — via a Resolving token's wee box or a direct tokenless close — and both paths sit behind a hard photo gate (422 snag_closeout_photo_required; the completion photo is dual-purposed). Close self-certifies: no verification state — a deliberate departure from industry two-step close (verification migrated to blockers). Soft-delete is the mistake path; no reopen edge — a resurfaced defect is a fresh snag, fresh clock. Snag clocks (raised_at → closed_at) are responsiveness metrics and never feed lost time. Visibility: owning team + principal side (HSE deliberately out — snags sit on the commercial axis, not the safety axis). See docs/decisions/2026-07-17-snag-own-work-caveat.md. _Avoid_: defect (Defect is now a first-class contractual noun — different parties, register and legal weight; no promotion path either way) / punch item / NCR (industry imports with heavier workflow); snag list / snag board (there is no standalone board — snags live inside the Completions view); blocker (obstruction, not defect).
Resolving token
A Token placed to fix open Snags. The *snag* holds a re-pointable polymorphic ref to it — so "fix scheduled" is derived from the live ref, never stored, and one corrective visit can fix many snags. It inherits the original work line by default; its record-work-completion wee box carries per-snag "fixed?" ticks (a shared token closing 3 of 4 is legal), and closing rolls the completion caveat away (98 → 100%). _Avoid_: rework token, corrective action (Action is the H&S close-the-loop entity — unrelated machinery).
Lost time
The unified, read-only rollup the commercial side reads: crew-hours = actuals window × stood-down headcount, derived, never stored — and the totals count blockers only (Colin: "lost time is the sum of those little blockers"). Snag rows appear as clocks-only responsiveness metrics with zero crew-hours. Capture is one nullable stood_down_headcount on the blocker detail (defaults from affected worker refs, editable; multi-crew = multiple blockers); the blocker's actuals window is the loss window, read against the record's lifecycle — three states, not two: resolved (a declared resumption has elapsed; the only costable one), live (none elapsed and the record still open — an age, never a loss) and undeclared (closed with no restart ever recorded, so no end, no duration, no crew-hours, and out of both totals with the omission counted beside them). Attribution: *loser* = the blocker's contractor; *responsible* = triage-accepted blame only, stored at triage confirm (pre-derived from the cause chain; ratified 2026-07-17). Served by read-only GET /api/lost-time behind lost_time_view; reviewed in the Lost time section of the Commercial full-screen surface (I) — a resolving door: lost_time_view lands on the site-wide ledger, blocker.view + a firm membership on the own-firm position view — with linked-brushing map coupling — choropleth off by default, a "review range" pill that never pokes the scrubber.
LedgerWalk
The Lost time ledger's one pass over the admitted rows, as a value. The pass was always one walk feeding a dozen carriers; what it had no name for was its own *result*, so every carrier the assembly needed was another accumulator threaded through the loop and another argument threaded through the assembly. walk() produces exactly one LedgerWalk and assemble() consumes exactly one, so a block that wants a new figure asks the walk for a field rather than growing the loop a parameter. It is frozen, because the walk is over by the time anyone holds one: accumulation happens inside walk, every consumer reads. (The collections it holds are ordinary lists and dicts — freezing fences *re-binding*, which is the seam that matters.) The three unfiltered indexes ride here too — the chain rows, the stopped-at map and the place accounts are each derived once per request, before the loop, and read by several assembly blocks; a second derivation is exactly the *"there is one answer to where a record was"* failure they exist to prevent (services/lost_time/lost_time_service.py, spec §3.3). _Avoid_: "the loop" (it is the loop's *result*); accumulator (a LedgerWalk holds the accumulators and is not one); ledger payload (the assembled wire shape, downstream of this).
Close-out (work sense)
An umbrella naming three distinct gestures, disambiguated by object: work-line close (the load-bearing "can the next trade start?" verb on a Work line), daily completion (a Completion record — optional and silent), and finished-early (an explicit whole-token early-done signal that removes remaining scheduled occurrences behind a confirm-N dialog — the *nuke*, never a ghost). Token expiry ≠ completion: a lapsed window proves nothing about the work. Distinct from Permit close-out (a permit lifecycle transition) and Action close-out (H&S). See docs/decisions/2026-07-17-close-out-two-verbs.md. _Avoid_: close out (bare, without naming which gesture); completion (only the daily record); expiry (a schedule fact, not a statement about work).
Work candidates
The shared suggestion seam every token-tagging mobile flow picks from: the actor's own or oversight-inclusive day-active work (POST /api/sites/{site_id}/work-candidates, scope=own|oversight), derived server-side from membership. "Mine" is eligibility, "near me" is ranking: an optional device fix (POST body, never a URL) only *orders* the list and computes the near_you fact — proximity never adds or removes a candidate, so denying location loses ranking, never function. Adopters: blocker raise, record-completed-work (snag wee-box inherits). Distinct from affected-suggestions (cause-anchored, cross-contractor, coordinator- facing). Decided 2026-07-18 (wayfinder map #1293); see docs/decisions/2026-07-18-transient-geolocation-privacy.md and docs/plans/2026-07-18-geolocation-work-candidates-spec.md. _Avoid_: nearby work (the ranking, not the universe); suggestions (bare — collides with affected-suggestions).

Daily site diary — the per-day multi-author record

Site diary
The per-day site record: SiteDiary, unique per (site, work_date), holding append-only immutable attributed Diary entry rows and rendering through the report template + PDF machinery via an adapter — never a ReportSession. Multi-author (whole-diary write grain within the gate), partially machine-answered (AUTO cells attributed to SimOps, no entry rows), published once per day by the Publish sweep. Gate: verified membership + contractor_id == site.main_contractor_id + write-band role — no role-band side door; subcontractors get nothing; the client reads the published PDF only. _Avoid_: consolidated report (the incumbent noun — collapses, nothing is merged); daily report (a ReportSession derivation, a different noun); diary as a report template (it renders *through* templates, it is not one).
Diary entry
One append-only immutable row: author, server-assigned written_at, shift_id captured at write by containment, section/question binding, and a kind — answer · nil_report · photo_dump · photo_exclusion · erratum. SUGGEST-backed answers store source ids plus the rendered-text snapshot at write ("stores what it rendered"). A nil-report ("nothing to report", attributed) is a different fact from a hole (no entry at all) — never string-matched; holes publish as visible gaps. _Avoid_: edit / amendment (no update path exists — the only post-publish kind is the Erratum).
Working period
The diary's day: [D @ boundary, D+1 @ boundary) site-local, boundary a per-site setting (diary_day_boundary, default 06:00). work_date is the period's day label. CompletionRecord.work_date derivation aligns to this boundary (an 01:00 close-out stamps the prior day) — the photo harvest keys on the work-day stamp, never uploaded_at. _Avoid_: calendar day (midnight-bounded — the site-TZ day contract's algebra applies, but the diary's boundary is the site's, not midnight).
Publish sweep
The boundary tick that ends a diary's day: computes AUTO cells (frozen into the revision's snapshot — never recomputed, so re-renders cannot change the body), renders the crane snip server-side, renders the immutable PDF (Rev A), and fires distribution (derived audience ∪ SiteDiaryRecipient top-ups, PDF attached). There is never a publish button; empty and hole-y days publish anyway — the gappy PDF is the enforcement. _Avoid_: submit / approve / sign-off (no such step exists anywhere).
Erratum
The only thing a published diary admits: an append-only attributed row, server-assigned timestamp, diary-write gate, no time window, photos allowed. Renders only in the Errata appendix of a versioned re-render (Rev A/Rev B, drawing-style; all revisions retained): the body never changes across revisions and holes stay holes forever. Humans may erratum against an AUTO cell — both stand, each attributed to its actual author. Re-distribution re-fires the morning-email machinery flagged as a revision. _Avoid_: amendment / correction (implies the record changes — it never does); addendum (a detachable loose sheet — rejected).

Defects — the imported contractual quality register

Defect
A contractual quality event: work not in accordance with the Scope, notified between the contracting parties under NEC4 (defined 11.2(5); corrected cl 43, accepted cl 44, uncorrected cost cl 45). First-class in SimOps as the seventh WorkGroup sibling (DefectDetail — WorkGroupType has seven members, defect the last minted), but on a site whose contract names a communication system (cl 13.2 — South Clyde names Sypro) the SimOps record is derivative: a defect is raised and accepted in the named system first and arrives by per-contractor CSV/XLSX import (upsert on (site, contractor, event_reference); rows are never deleted on import — statuses move, registers don't shrink). The upload's contractor scope *is* the defect's contractor; parties are not modelled. Status is the observed register vocabulary (awaiting_correction → corrected → acceptance_proposed) with closure a separate closed_at/closed_by overlay — "corrected but not closed" is one party's claim, not settled fact; open = closed_at IS NULL (the diary's QA/QC count is this query). Visibility mirrors Sypro's private pages: owning contractor's members see their own register; principal set (commercial in — cl 45 money; HSE out — quality axis, not safety) sees all; not main-contractor-gated, unlike the site diary. Native lifecycle (raise/correct/certify/close in SimOps) is the design goal and is deferred by name. A defect is not a Snag and there is no promotion path — escalation happens in the named system, never by conversion. See docs/decisions/2026-08-10-defect-domain-object.md. _Avoid_: NCR (ISO 9001 vocabulary, not carried); Defect Notification (Sypro product noun); snag (own-work caveat, different register and legal weight); punch item.
Defect evidence
A close-out photo citing an imported Defect — *this photograph evidences the correction of defect X*. The twelfth file-evidence kind (DefectEvidence), the snag's evidence row minus its phase tag: phase exists to feed a close gate, and SimOps has no close verb for a defect to gate. Created by one verb only, adoption: a photo already attached to a Completion record gains a second row sharing its storage_key — one blob, several duties, no byte copy — so the bytes outlive any one of those rows being deleted. A reference, never a raise: it changes no fact about the defect (not status, not closed_at, not prevents_completion, not the diary's QA/QC counts), which is precisely why it sits outside the no-promotion-path ruling — annotating a derivative record is not a Snag becoming a defect. Offered against open defects only (closed_at IS NULL, so "corrected but not closed" IS offered — that is where a contractor's supporting photo belongs); many defects may cite one photo. Written by whoever can both *see* the defect and attach the photo (defect.view ∧ entity.edit) — no permission token of its own. See docs/decisions/2026-08-10-defect-domain-object.md D7 (amended 2026-08-21). _Avoid_: defect photo (the register's own Sypro documents are not this); raise (nothing here mints a defect); proof / verification (a photo is one party's claim about a correction, never its acceptance).

Programme import — staging, promotion, living sync

Programme
The durable, site-scoped series identity of a P6 schedule — a container of Programme revisions, never itself a file. Carries an optional contractor_id: set, it is a per-contractor programme and that contractor is the default contractor every band resolves to when nothing narrower names one; null, it is a master programme spanning trades and attribution is wholly per-band / per-line. Contractor resolution order, one authority consulted at read time and never copied into rows: per-activity Activity mapping → Band mapping → programme default → none. Carries the remembered Band mapping and the two diff-basis pointers (current_revision_id and the Logic pointer). A site may hold several — a client's master beside a subcontractor's own. Amended 2026-09-01 (#3605, #3611). _Avoid_: plan (the removed planning-cycle vocabulary); project (P6's own noun for the file contents); "the master programme" as a synonym for Programme (it is one kind); "programme contractor" for a band's resolved contractor (say default contractor).
Programme revision
One immutable handed-over programme file (P6 XER, a native Powerproject .pp, or a PDF print), staged as WBS nodes + activities + relationships that are never mutated after ingest. revision_kind (baseline / progress_update / ce_impact) is metadata — CE fragnets are revisions of the *same* series. The raw file lives in object storage (ultimate provenance). source_format (ProgrammeSourceFormat: xer / pp / pdf / xlsx — xlsx reserved, no v1 code emits it) is the single fidelity switch: xer and pp are the two full-fidelity profiles (identical on every question a seam asks — logic, wall-clock dates, absence is a signal); a pdf revision is *degraded* — lines + dates only, day-precision, and Edge-blind. The door infers the format by content sniff (SQLite magic / ERMHDR / %PDF), never by extension; bytes matching none are refused at the door. Amended 2026-09-07 (map #3749, ADR 2026-09-07-programme-import-second-source-format-powerproject.md). _Avoid_: version (feature-versioning vocabulary); import (the act, not the artifact); "Powerproject fidelity" (there is no fourth axis — what the file lacks is a per-row fact).
Staging
The role the Programme* tables play: imported truth held at arm's length, browsable but inert. Staged rows never touch the curated graph uninvited — the "only critical chains get lines" philosophy survives the ~450-row firehose. Staging is a role, not a noun: the rows remain what they are after promotion links to them.
Promotion
The coordinator act (gated work_plan.manage) that mints Work lines from a curated selection of staged activities — 1 activity → 1 line, on a chosen ready revision (default latest). Pre-filled from the Band mapping; milestones become zero-duration lines; LOE/WBS-summary rows are hard-excluded; status maps TK_* verbatim (including complete — history has no evidence gate). Auto-materialises staged logic between promoted lines as edges, behind a mandatory preview ("N edges — M violated") and a per-act notification digest. The inverse verb is detach (nulls provenance, keeps the code, event-logged). _Avoid_: import (upload/ingest — promotion is the later, curated act); approve (permit vocabulary).
Band mapping
The per-Programme memory Band path → default contractor + place/level, filled once at first promotion (the wizard ceremony), overridable per activity (an accepted override persists as an Activity mapping), reused by sync for new arrivals. Carries two kinds of curation memory beside the mapping (amended 2026-09-01): a persisted hidden flag — the curated-out band, gone from the grid and the Plan overlay (replaces the old admin-chain regex chip) — and the place suggestions the coordinator rejected, remembered until the band's name chain changes. No fuzzy auto-matching ever writes — a place suggestion (name-chain overlap against place names + aliases, floor 0.6, children inheriting the parent's place, exact ties a two-chip pick-one) is derived, recomputed every sync, and reaches the row only through an accept; *Accept all* takes exact matches only. _Avoid_: band suggestion as a stored thing (it is derived); admin chains (the retired regex — hide the band instead).
Band path
A WBS band's cross-revision identity: the root-stripped, verbatim-case chain of wbs_names from the highest in-file ancestor *below the project node* to the band, joined with › . The short_name chain (ordinals on every real file) is never an identity — it lies silently across a re-scope — and serves only as a tie-break inside a Band rename proposal. A path that no revision carries is a miss, never a guess. The last segment is the band's label; descendant matching is structural (segment lists), never a string prefix. Decided 2026-09-01 (#3612). _Avoid_: short_name path / natural path (the retired ordinal chain); band id (no surrogate exists).
Band rename
The sync-review facet and apply write that moves a Band mapping from a Band path the target revision no longer carries to the unmapped target band holding its activities. Candidates come from subtree activity_code overlap only (largest unique share; short_name chain breaks a tie); a whole move (every surviving code in one destination) is proposed checked, a partial move unchecked; written only at Apply, event-logged. A pending rename never paints the map. An orphaned mapping stays and self-heals on the name's return. Decided 2026-09-01 (#3612). _Avoid_: re-key (the *line* verb — an identity assertion with no heuristic); fuzzy match (no name similarity is ever scored).
Activity code
A P6 classification stamped on an activity — a type (AREA, OWNER / SUPPLIER, STAGE…) and one of that type's values (BOILER HALL, CAREYS, STAGE 5) — staged verbatim and vocabulary-blind on every Programme revision as code values + activity assignments. A code means nothing to any seam until its type carries a Code role; every type earns a Programme-grid filter chip regardless. Decided 2026-09-01 (#3611). _Avoid_: activity code for the P6 task_code (that is the activity_code, the opaque natural key); UDF (a different P6 table — productivity here, never location); tag.
Code role
A per-Programme declaration, keyed on an activity-code type's name, that its values name a place (ranked among the programme's place-role types; first ranked type with an alias-matching value wins) or the contractor (at most one such type). Proposed from the site lexicon, accepted by the coordinator, never written silently; a role whose type a revision lacks stays, muted. A role is what lets a code propose (where nothing narrower resolves) or contest (the ⚠ badge, where something does). Decided 2026-09-01 (#3611). _Avoid_: code mapping (the mapping is of bands and activities to the site; the role is a reading rule); location code (P6's own LOCATION table — absent from every file seen).
Site lexicon
The site's whole-phrase vocabulary: every place (Reference feature with geometry — a building, a zone, any other named feature) and every Contractor, each by its name, short code and aliases. One module (services/site_lexicon.py) answers "what does this phrase name here?" for permit import, programme place suggestion, Code role proposal and the code answers alike. Its laws: matching is whole-phrase (an alias is another exact key, a substring is an absent key — P2); a name beats an alias whatever order the two were registered in; a building beats a zone bearing the same name; a placeholder is not vocabulary — "Site wide", "TBC", "N/A", "Various", "None" name nothing, so a feature so named answers for no phrase and a band so named proposes nothing; and only geometry-bearing features are places (a feature nothing can paint or clash against is not somewhere a permit lands or a band is suggested). The fold every matcher shares — lowercase, strip, collapse whitespace — is core/phrase.normalise_phrase, and the wire's alias tidy rule folds through it too. Decided 2026-09-02 (#3691, epic #3651). _Avoid_: "resolver lookup tables" (the permit resolver *holds* a lexicon, it is not one); "site dictionary".
Activity mapping
The coordinator's accepted per-activity override — contractor, place, level — keyed by activity_code on the Programme, the top rung of the contractor / place resolution order and the durable home of an inline override before promotion. Written only by an accept; read by the Plan overlay's ghosts and pre-filled into promotion. The per-activity twin of Band mapping. Decided 2026-09-01 (#3611). _Avoid_: selection override (the promote payload's transient field, which this pre-fills); activity override as a WorkLine concept (post-promotion the line's own columns are the truth).
Sync review
The facet-grouped reading of what a newly-ready revision changes versus the current diff basis: dates / names / logic / orphans / new codes / status disagreements / renamed bands (Band rename, added 2026-09-01). Delta semantics, not reconciliation — staged rows unchanged between revisions are never touched, so SimOps-side drift is never re-litigated. Fully attended in v1: upload alone changes nothing. A change of the programme's default contractor or of a Code role has no facet — they are read-time defaults, not deltas.
Apply
The single coordinator act committing a Sync review: planned windows (blended dates) and names track the revision; placement, contractor, tokens, evidence preserve; status is surface-only both directions (advance goes through the close verb). Moves current_revision_id (and the Logic pointer iff the revision carries logic — XER or pp), bumps promoted_from_revision_id (a freshness marker, not a birth certificate), writes per-change events. Never mints lines — new codes are a signpost into Promotion. Edge replay: added → materialise, removed → remove, re-lag → remove+re-add, unchanged → untouched (the free resurrection guard for hand-deleted edges).
Orphaned line
A promoted line whose (programme, activity_code) no longer resolves in the current revision — derived, never a column; reappearance self-heals. Verbs are existing ones: keep (default), detach, close/delete — never auto-anything. Orphans keep last-applied dates; the badge explains the staleness (softened copy when the source revision is a PDF).
Re-key
The coordinator-band identity assertion for the renumber contingency: editing a promoted line's activity_code to the new code ("this line *is* that activity"). Unique-index guarded, event-logged; the whole merge-key fallback — no heuristic matching anywhere.
Logic pointer
Programme.current_logic_revision_id — the edge-facet diff basis, stamped only by logic-carrying applies (XER, and pp since 2026-09-07). Keeps PDF applies edge-no-ops and makes an XER-after-PDF apply diff last-XER-vs-new-XER, never "everything added". A .pp apply moves it like an XER; mixing the two full-fidelity formats on one series is an ordinary delta.
Edge-blind
The epistemic stance toward a PDF revision's missing logic: no information about edges, never evidence of absence. A PDF apply touches no edges; a PDF-sourced disappearance badge carries softened copy. _Avoid_: edge-empty (implies a true zero).
Born satisfied
An imported or hand-authored edge whose predecessor line is already complete at creation — legal, inert, the truth of history. Drains most of a mid-project import's violation storm.
Slip chip
The variance atom: a signed-days RAG chip (+14d) comparing a promoted line's planned window against site reality — derived at read time, never stored; thresholds are a per-site setting (default amber +1..+5d, red >+5d). Renders in the board variance lens (toggleable; lens off = board untouched), the weekly programme health digest (the Monday programme_health_digest sweep) and — since 2026-09-01 — the Reality tab's Plan overlay (per-bar chip + per-row rollup "worst +Nd · n late", and the choropleth's *behind* mode as worst slip per place), all reading one authority (/work-lines/variance). Milestone rows carry a bounded worst-ancestor slip forecast — deliberately not CPM. See docs/decisions/2026-07-21-plan-vs-reality-variance.md.
Sibling pair
The same programme revision handed over in two source formats — the SCEC rev-36 native Powerproject .pp beside Careys' P6 Cloud XER of it; each synthetic revision A / B emitted by both fixture writers. The unit the Equivalence contract is measured on. Decided 2026-09-07 (#3760). _Avoid_: "the pair" for a revision A→B sequence (that is a revision pair, one format, the delta list's subject).
Equivalence contract
What two parsers must agree on for a Sibling pair, as four lists over the leaf activities — *exact* (byte-equal per activity code) · *rule* (equal under a named normalisation with the count the real pair expects) · Named allowance · *delta* (the staged sync delta from one format's A→B equals the other's). Symmetric on the shared set; anything unlisted fails, including a skip reason a parser mints that no list names. One comparator in test support, two callers: the synthetic pair in CI, the real SCEC pair as an opt-in local gate (SIMOOPS_PROGRAMME_PAIR_DIR, make programme-pair-gate, outside make test). Decided 2026-09-07 (#3760, early dates re-listed by #3757). _Avoid_: "parity test" (the P6 clash gate's word); "real-pair gate" as a domain term (it names the opt-in test only).
Named allowance
A fact one source format carries and the other cannot — the XER's WBS-summary activities, late dates, float and driving flag; the .pp's overall-percent-complete — asserted present on its side and absent on the other, so a parser that starts inventing it, or an emitter that stops planting it, trips the suite. The list is closed: an asymmetry not named is a failure. Decided 2026-09-07 (#3760). _Avoid_: tolerance / fuzz (an allowance is exact about which side holds the fact); "known gap" (it is asserted, not tolerated).
Summary axis
Powerproject's own hierarchy — EXPANDED_TASK → BAR → TASK — and the fixed source of a .pp revision's Band path: every expanded task except the root chart (IS_PROJECT = 1, stripped structurally like the P6 project node) and the Orphanage. Bars are never a band segment: a multi-task bar's tasks sit on the enclosing expanded task, the bar name rides the activity's bag. WBS_ENTRY (P6's PROJWBS, identical below the root on SCEC) rides the bag as evidence and is never chosen per file. A coded expanded task is a band, not work — the .pp parser emits leaves only. Decided 2026-09-07 (#3756). _Avoid_: WBS as the .pp hierarchy's name (that is the optional overlay); axis detection (no file chooses its own axis).
Orphanage
Powerproject's second top-level chart beside the root — where a planner parks tasks removed from the programme — found structurally (any non-root top-level expanded task), never by name. Its tasks are counted under the skip reason orphanage_task and never staged: a parked task is not work the programme carries. Decided 2026-09-07 (#3756). _Avoid_: Orphaned line (a promoted line whose code left the current revision — a different orphan on a different plane).
Skip channel
The one place an ingest reports what it could not use — the revision's skipped_relationships reason→count dictionary — and, under the Equivalence contract, an exact per-format value. XER reasons: cross_project · unknown_link_type · unusable_task_row · unusable_actv_code_row · dangling_code_assignment. Powerproject reasons: duplicate_activity_code (the merge key is verified, never assumed) · orphanage_task · summary_endpoint (logic attached to a bar or expanded task) · skipped_endpoint (an endpoint on a task the parser itself skipped) · dangling_code_assignment (+ the shared unknown_link_type); cross_project is never minted for a .pp. A completed-section fold is resolution, not a skip. Decided 2026-09-07 (#3756, #3760). _Avoid_: warning list / error list (a skip is a counted, named drop, not a fault — faults end the revision failed with an ingest_error_code).

Blockers — declared work stoppages

Blocker
A declared, first-class record that a cause stopped or impaired specific work over an actual time window. Substrate: a WorkGroup (group_type=blocker) + 1:1 detail extension — the same minting pattern as Incident and Observation — inheriting comments, contractor tagging, Position provenance and the active → resolved → archived lifecycle. It is the sixth Auxiliary pin kind; geometry is nullable — when the cause is itself a pin the blocker renders as a badge on the cause, and only pinless causes ("council closed the access road") get their own pin.
Blocker acknowledgment
An optional coordinator counter-signal on a Blocker — acknowledged or disputed, with a note — that never gates the record's existence. A contractor declaring their own work blocked is self-serving evidence; the contemporaneous acknowledgment (either direction) is the second opinion claims tooling weighs. Decided 2026-07-06 (grill session).
Relayed report
A Blocker raised by a desk person from a message somebody else sent them — WhatsApp, email, a phone call, a word in the cabin — as opposed to a first-hand raise by the person who saw the work stop. It carries three facts *as told to us*, none of them verified by the platform: who told us (a name, free text, never a user account), the channel (whatsapp / email / phone / in_person; absent = first-hand), and when they told us (a third instant beside the actuals window and the declaration stamp; it pre-fills the stop instant and is shown on the record face). The declarer stays the desk person, and the ledger's recording lag reads the declaration, never the relay — the relay instant is unverifiable and would let a desk type promptness. A relayed count is prose in the description, never the firm's word: the firm speaks it in *Needs your count*, or the meeting's attested-count arm retypes it with the firm's rep present. A relayed photo is an ordinary gallery pick: the app claims a taken-at instant or a Sensed location only where the bytes carry them, and never the reporter or the reported-at instant from a file. Decided 2026-09-18 (map #3810 ticket #3817). _Avoid_: reporter (the filing user on an Observation / Incident, who holds see-own powers this name never grants); on behalf of (implies delegated authority); forwarded (a property of the message, not the record); second-hand (reads as untrustworthy, where the point is only that it is unverified).
Attested count
A firm's send-home count typed by a blocker_triage holder at the coordination meeting with a named representative of that firm recorded present — stored as the firm's word, never the coordinator's. The count write carries attested_by {name, user_id?}; the record's updated event holds the name, the firm and the site-local date; the basis bundle reads *"spoken by ⟨rep⟩, ⟨firm⟩, at the ⟨date⟩ meeting"*. No column — stood_down_basis stays declared, and a later own-firm correction is a later act. It is the one exception to the count being the owning firm's own act (blocker_count_not_yours for every other caller) and the only way a triage holder types another firm's figure: a count in a forwarded message stays prose (see Relayed report). The *Present for ⟨firm⟩* declaration is per sitting, per firm, client state only — there is no meeting entity. Decided 2026-09-16 / 2026-09-18 (map #3810 tickets #3811, #3819). _Avoid_: witnessed count (the *blamed ⇒ witnessed* rule this arm amends, not the act); proxy count (the proxy band never types a count — A-F1); meeting count (it is the firm's count, spoken at a meeting).
Meeting mode
The Completions → Blockers tab's second reading — the coordination meeting's table, where the coordinator runs completion and triage on one row. Two bands: every row still awaiting triage, however old (closed-but-suggested included — silence is not a decision), and the rows raised, confirmed or rejected inside the daily report's site-local day window; no "meeting held" stamp. A band-one row expands in place into the confirm form (one triage-form component, two hosts — the modal and the row), hosts the discussion thread as the rebuttal and the completion fields (locked until a rep is present — see Attested count); rows decided during the sitting stay put with a *decided* mark. Entered from the daily report's Blockers section (bidirectional — closing the table restores the report on the same draft session) and from the Noticeboard's awaiting count, which is a door, never rows. Prints as confirmed rows plus one *"N raises awaiting triage, not listed"* line. The push is untouched: every raise still mints its confirm_blocker Task. Decided 2026-09-16 / 2026-09-18 (map #3810 tickets #3811, #3819). _Avoid_: meeting (as an entity — none exists); triage queue (the Task is the push, this is the table); "Lost time → Blockers" (a mis-naming — the host is the Completions surface's Blockers tab; crew-hours and £ live on the Lost time Ledger).
Stand-down
The people-measure inside a Blocker — how many operatives were stood down (site verb: "we were stood down"); substrate stood_down_headcount. A measure, never a record type: the Blocker owns the window and cause, the stand-down counts heads. Plant "stands", people "are stood down" — the site-language object split. Decided 2026-07-25 (wayfinder map #1742).

Inventory — materials and stock

Delivery line item
One manifest row on a delivery: free-text description, quantity, unit (plain string; per-site suggestions; no unit conversion, ever — units merge only on equality), and an *optional* Material reference. The manifest is what a delivery *says* it carries; what actually arrived is established by Receive. _Avoid_: cargo, payload; manifest (the collective, not the row).
Material
A thin, site-scoped catalog entry ("rebar 12mm") that line items may reference — the Permit type shape: seeded vocabulary, adapted per site, never forced at entry time. Cross-delivery aggregation ("how much 12mm rebar is on site?") exists only where the reference is set. _Avoid_: product, SKU (implies an org-level catalog; this is per-site).
Stock lot
The received instance of a Delivery line item — the thing that is physically *somewhere*. Carries a polymorphic current-location attribute (laydown ActiveArea FK, point with Position provenance, building + level, or unassigned) as a cache; every location change is a transfer Stock event, so history lives in the ledger. Stock is not time-windowed: when a laydown area's window elapses with stock still in it, nothing auto-moves — an orphaned-stock advisory surfaces it (the elapsed-but-unresolved house pattern). _Avoid_: batch (process-industry connotation); inventory item (the ledger tracks lots, not catalog rows).
Stock event
One signed-delta entry in the append-only stock ledger: delivery_in / consumption / loss / transfer / adjustment, each referencing its cause (delivery, incident, token/permit/area, or a note). Contractor + quantity are mandatory; finer attribution is optional polymorphic refs. Balances are always derived from events; corrections are compensating entries. An incident-linked loss posts immediately — never gated on the incident approval workflow. _Avoid_: stock movement (transfers are one kind, not the genus); stocktake (an adjustment's occasion, not a ledger concept).
Receive
The explicit workflow action on a delivery pin that turns manifest into stock: pre-filled from the line items, edited to actual received quantities (short loads, part-rejection), posting delivery_in events. Damage is not a quantity edit but a first-class ReceiveException authored inside the sign-off (ReceiveExceptionKind: damaged / wrong_item, its quantity deliberately not bounded by the received quantity). Receipts are repeatable, with an explicit final declaration; sign-off IS receive (#1279, ADR I7) — a receipt pre-filled from an extraction draft flips that document to confirmed atomically. Status changes alone never post stock — completion is not receipt. _Avoid_: check-in, goods-in (dockyard jargon); complete (a delivery status, deliberately distinct).

Document intake

Ingest
Channel-side arrival of a document. Shipped: a file landing via an upload route. Designed-for but unshipped: the email channel (the intake spec explicitly declines an IngestAddress table or EmailIngest model for now). Says nothing about what happens to the document next. _Avoid_: using it for the whole pattern (that's Document intake).
Document intake
The pattern itself: a document arrives (Ingest), its genre is declared by the channel's affordance (a button, an address prefix — never a classifier), Extraction optionally drafts structured content, and a human's confirmation IS the domain write — nothing provisional touches domain tables. Degrades to manual entry when no provider is configured. A vertical riding the pattern is a document family (manifest documents, order documents); each family brings its own sibling <X>Document + draft workspace — families share idioms, never tables. _Avoid_: document ingestion (collides with the email-channel term); OCR pipeline (extraction is one optional stage, not the pattern).
Extraction
The LLM step only (services/extraction/): a provider adapter (ExtractionProvider, Claude API / OpenRouter) with one typed method per genre, run as a TaskIQ job, writing a draft into the document row. Never runs unrequested; logs provider/model/tokens, never content. _Avoid_: conflating with Document intake (the pattern survives extraction being absent).
Tabular intake
The spreadsheet half of arriving data, and a different thing from Document intake: no LLM, no genre declaration, no draft — a .csv/.xlsx whose columns a per-customer YAML config maps onto domain fields. services/intake/ owns everything domain-blind about that: the bounded read and the CSV/XLSX grid every tabular intake reads through (permit register, defect register, bulk import), the config substrate, the two-phase TTL cache that carries an import_id between the preview and the commit, and the transform registry — the named pure functions a config may name against a cell. What a row *means* stays per package. The registry is the seam that matters: parse_datetime, strip and to_string are shared and know nothing about permits or defects, because a private copy of one is how "the same transform name" came to mean two different things in two intakes (#2814). A client's registry spreads the shared set and adds only the transforms its own domain knows (permits add split_values and regex_extract). TransformError lives with the applier, so "a refused cell is a visible error row naming row, column and value" is one contract shared rather than copied — the two register imports report refusals identically, and a refused row is never a silent skip (#2555). Wall-clock cells take their zone resolution from the one home (core/timeutil) and then refuse rather than fall back: an unknown IANA key misreads every cell in the upload, so it fails the whole file rather than becoming a page of identical row errors. _Avoid_: Document intake (the LLM pattern — different verbs, different tables); "the parser" (a config-driven parser is one client of the grid, not the substrate); "importer" (ambiguous between the grid, the parser and the domain write).
Spatial intake
The file half of arriving data — a GeoJSON reference layer, a shapefile ZIP, a site-map GeoTIFF, a DXF floor plan — and a peer of Tabular intake, not a client of it. Each arrival path is a leg; every leg does the same six things (stage → byte cap → parse → persist/version → audit → broadcast → reap), and until #3108 each hand-rolled all six. Seven legs, all of them on the spine: GeoJSON, shapefile and site-map joined in #3108; the DXF import, the DXF pre-flight and the two raster floor-plan legs in #3109 — which is when the last hand-rolled ceilings went (a 413 carrying no slug, and one number checked by two authors). The intake spine (services/intake/spatial.py) owns the domain-blind half: the suffix gate, the byte ceiling enforced *while* the upload is staged (so an over-cap file costs one chunk of disk and zero parser work), the off-loop parse, the ordered parse → land → announce, and one scratch directory reaped in finally on every path. An IntakeAdapter is that leg as a *value* — kind, suffixes, byte_cap, parse — declared beside the parser it names and holding no session, user or request state; the request-scoped half reaches the spine as the land (persist/version + audit + the durability point) and announce (post-commit broadcast) callables. The upload itself reaches it as a SourceFile — filename, claimed size, stream — so a leg whose door is not a multipart route (the DXF launch saga, deliberately transport-free) rides the spine without inventing a request. The claimed size is only ever an *arm*: a chunked upload declares nothing, so the authority is the count kept while the bytes are written. The ceiling is a whole-upload refusal, never a partial import: over-cap is InvalidInput 422 carrying upload_size_exceeded, the same shape on every leg, mirroring geometry_limit_exceeded (the vertex/extent caps) and multiple_shapefiles_in_zip (the multi-layer archive). The three are distinct questions and each answers with its own slug — bytes, geometry size, archive shape — so a client branches on the slug rather than on prose. _Avoid_: "upload validation" (the cap is one of several refusals, and the adapter is not a validator); reading byte_cap as the geometry caps (#2946 guards vertices and extent, not bytes — they compose).
Gallery entry
What one attached file looks like on the wire, for every one of the twelve file-evidence kinds. Seven fields: the six the file says about itself (id, filename, content_type, size_bytes, uploaded_at, uploader_user_id) plus download_url — a same-origin backend streaming path, never a presigned S3 URL, and the only address a reader has for the bytes. Declared once as app/schemas/gallery.py::GalleryEntry (#3128); each kind's read schema inherits it and may add a field it alone needs (completion's caption, inspection's item_key, snag's phase, a Permit attachment's expected_slot_key) but may never restate a shared one. EvidenceDescriptor is the base minus the URL — the shape of a kind that declines to be a gallery. Exactly one does: the stock-event pour ticket, a descriptor list served from its own route where the client addresses the bytes from ids it already holds. A named decline, with its reversal condition ("if the wire ever grows a download_url, enrol it") written down beside it. No gallery entry carries storage_key. The raw S3 object key is the server's address for the bytes; a client holding one can do nothing with it — prod's reverse proxy exposes no S3 route — except learn the bucket layout. The COLUMN is untouched on every evidence row: the download route reads it to fetch the blob, and db/storage_scope enumerates it to answer whether anyone still names those bytes. _Avoid_: "evidence response" (twelve classes, one contract — the noun is the contract); calling the stock ticket a gallery entry; reading the strip as a column removal.

Claims evidence — QS tooling

Evidence pack
A persistent, attestable bundle of contemporaneous records over declared parameters (time window × area × contractor × record classes): a frozen context snapshot plus a branded narrative document and raw data annexes, generated by the report pipeline's provider DAG. At generation an append-only attestation records a content hash over pack + snapshot + parameters — "this pack, these contents, this moment". Permit windows/timelines serve as the in-system as-planned leg; blockers, audit, clash episodes, weather, and the stock ledger are as-built; the external P6 programme stays external. A Claim workspace (heads of claim, valuations, lifecycle) is deliberately not an entity. The pack workspace surfaces as the Evidence tile of the Commercial full-screen surface (I, gated evidence_pack.manage) — not a standalone dock panel (originally a 2026-07-13 Analysis-suite tab; the suite dissolved into the domain homes, ADR 2026-08-12-dock-domain-homes.md). _Avoid_: claim (the QS's argument, made outside SimOops); report (the daily coordination artifact); export (an ephemeral download; packs persist).
Commercial (role)
A site role for the QS/commercial persona, minted in the hse pattern: sitewide read (cross-contractor by necessity), evidence-pack generation, export, audit_view — plus the commercial blocker band (blocker_edit / blocker_manage / blocker_suggest — blockers are, deliberately, the one record a QS edits; blocker_triage is withheld), lost_time_view, hse_exposure_submit, snag_view and defect_view. No other entity mutation or triage/workflow verbs. Exists so claims pilots never start with a coordinator over-grant. _Avoid_: QS role (persona, not the role name); quantity surveyor (ditto).
Completion record
A declared, evidence-backed statement that the work a token represents was done on a given site-local day: keyed by (entity, work_date) — one per token per day, which is what makes all-day (unwindowed) tokens completable day by day. Covers the temporal clashable kinds — those that live on the day axis, carrying start_at/end_at (Tokens, ActiveAreas). Not deliveries, whose completion-with-evidence moment is Receive; and not roads (nor buildings/zones), which are persistent geometry with no temporal columns at all — no work date names their work, so there is nothing to complete. Note the test is "has a time axis", *not* "has a non-null window": an all-day token has a null window and still completes. Living, not frozen: reversible and editable within the declaring contractor's authority (the require_contractor_access seam), with every change on an append-only event strip so contemporaneity stays provable for Evidence packs. Surfacing only — completing never mutates the token's window or geometry and never removes it from clash detection. Amended 2026-07-17: daily completion is optional and silent — no nudge, no visual guilt; the QS as-built takes whatever exists (Close-out (work sense), one of three gestures). Every record-work-completion gesture carries the Snag wee box. Capture is per token (properties panel, mobile record-completed-work) or via the site browser's multi-select bulk close — the My Work *Close out* tab (decided 2026-07-13) is retired with the panel's rename to *My Tasks*. Token expiry ≠ completion. Decided 2026-07-06 (grill session); road retired as an anchor 2026-07-15 (the axis is temporal, not clashable — D2 correction). See docs/decisions/2026-07-06-token-completion-records.md and docs/decisions/2026-07-17-close-out-two-verbs.md. _Avoid_: completed status (Delivery's enum value — a different lifecycle); close-out (the Permit close-out transition); sign-off (a possible future QS attestation, deliberately not this); "every clashable kind" (roads clash but never complete).

Permits to Work

Permit
A formal, time-bounded authorisation for work on a site: who (contractor), what (scope of work, controls), where (location), and when (a start/end window). One entity regardless of how it arrived — via the in-app request/approval workflow or via file import from an external allocation tool (provenance is recorded). A permit answers *"is this allowed to happen here?"* — it sits on top of the RAMS process, it does not replace it. _Avoid_: permit number (that is one field of a Permit, and historically the only vestige of one); PermitSet (the import batch record, not the permit).
Permit-linked entity
An on-map entity that can carry a permit_id — i.e. trace to a Permit. Five kinds today: Worker, Plant, Delivery, ActiveArea (the schedulable polygon areas — work_area / exclusion / laydown / HRCA) and ReferenceFeature (reference geometry — zone / road / building). This is the entity set the inline permit-request flow and the Permit coverage policy reason about.
Permit type
A per-site template defining a category of permit — its name and code (e.g. Hot Works/HW), the pre-checks a requestor must complete, whether it requires a parent permit, and its default duration. Sites set their own list (per HSG250 practice); a UK/EU default set is seeded and sites adapt it during onboarding. A single permit may carry several types at once (see Permit control category); the type catalogue is the vocabulary, a permit selects a set from it. _Avoid_: org-scoped template (permit types belong to the site, mirroring how site rules define the permitted-activity list).
Permit control category
The set of hazardous-activity controls a single permit authorises — a permit is *"Hot Work, Confined Space, General"*, not one thing. Each category is a Permit type drawn from the site catalogue; the permit carries a set. The set is the permit's identity for two purposes: it drives the clash engine (a Hot-Work category near a Confined-Space category conflicts) and the card's at-a-glance chips. The governing workflow regime (stages, parent requirement, extendability, pre-check checklist, duration) is derived from the set by most-restrictive/union rules — there is no single "primary" type. General is the near-universal base category and is visually de-emphasised. On import, the external tool's free-text *Control* column is the source of the set.
Permit hierarchy
A parent/child relationship between permits, expressed as a capability of the permit type system (requires parent), not a universal invariant. The UK default templates model the common principal-contractor convention: a General Permit (authorisation to work on site, mostly free-text scope) parents high-risk children (Hot Works, Confined Space, …) which inherit its location and contractor, carry their own parent-nested windows (not the parent's dates), and add type-specific controls. Sites that only permit high-risk activity simply use types with no parent requirement.
Permit family
A General (umbrella) Permit plus its single-issuer child permits — "two permits stapled together, both with their own issuers" — each running its own lifecycle, window, sign-off and authorised issuer, grouped under a shared parent_permit_id. The family is the structural answer to cross-discipline work (distinct authorised issuers); a lone permit carrying a Permit control category set is the degenerate "family of one" for same-issuer control bundles. The family is the clash unit — a token's clash categories are the temporally-gated union over its whole family (parent + open children), so decomposition never goes clash-blind. Parent transitions gate on child aggregate state (close-out blocked while any child is non-terminal; "work begins" blocked until children are live). Decided 2026-06-18 (grill session) — "P2-soft": families primary, multi-category re-scoped (not removed). ADR landed: docs/decisions/2026-06-18-configurable-permit-workflow-engine.md (amends 2026-06-16). _Avoid_: multi-category permit as the cross-discipline answer (that is the same-issuer control bundle); reading "stapled together" as mere display grouping (each child is a real, independently-signed permit).
Permit attachment
A file (RAMS, drawings, isolation certificate, lift plan, photo) uploaded against a Permit as the evidence behind its declared controls and pre-checks. Bytes live in S3; a row carries the metadata, reusing the same storage substrate as incident/observation evidence. Attachments are editable only while the permit is a draft (or in corrections), and their identities fold into the permit's signed content hash — so each approval signature attests to the exact file set, and a post-approval swap is detectable. A new or revised document after that point means corrections (pre-issue) or a reissue. Each file may be tagged to an Expected-attachment slot; a slot marked *required* must be satisfied before the permit can be submitted. _Avoid_: "evidence" for a permit's files (that is the WorkGroup / incident / observation noun — a permit has *attachments*, even though they share the storage helper); treating attachments as mutable after submit (they freeze).
Expected-attachment slot
A per-Permit type keyed prompt for a document ({key, label, required}, e.g. Confined Space → *Rescue plan*) against which a Permit attachment is tagged (expected_slot_key). Slots union across a permit's category set, deduped by key. A slot is either advisory — a satisfied/unsatisfied checklist that gates nothing — or required, in which case it gates submission: a Permit cannot leave draft until every required slot has a tagged file. required ORs across the category set (required by any contributing type → required in the union) and is enforced by the required_attachments_present workflow guard on the submit transition, not by a parallel check. _Avoid_: "expected attachment" for the *file* (the slot is the prompt; the file is the Permit attachment); assuming all slots block submit (only required ones do).
Permit coverage policy
A per-site policy stating that every placed Permit-linked entity must trace to an active Permit. When enabled, unpermitted entities are surfaced (badges, noticeboard signals), not blocked at placement — paperwork lag must not break the map-first workflow. Disabled on sites that don't run general permits.
Permit lifecycle
The fixed state machine every in-app permit walks: draft (requestor-only visibility) → requested → endorsed (first approval: coordination check) → approved (second approval: H&S desk review — on the permit schedule but not yet live) → issued (the walk-down: issuer and receiver both sign; work may proceed) → closed (formal close-out signed off). Rejected is terminal and available at all three gates; each gate can also send the permit back for corrections (requestor edits and resubmits — the full chain restarts). Imported permits join the machine at *issued*.
Workflow definition
The per-site (optionally per-Permit type) data artifact that *is* a permit's Permit lifecycle — a versioned graph of State marker-tagged states and action-keyed edges (each carrying its Actor tokens, guards, and effects), seeded with the default lifecycle and clone-to-customised. The test-only FSM Machine promoted to a runtime source of truth; admins compose it from a fixed registry of dev-authored guards/effects/handlers and never author logic. Each permit pins the workflow_version_at_creation it began under, so edits never strand live permits. Built as a domain-agnostic substrate (an Incident or Observation may adopt it later). Decided 2026-06-18 (grill session); ADR 2026-06-18-configurable-permit-workflow-engine.md. _Avoid_: state machine (the shape is now data, not code); workflow *instance* (a permit walking a definition is not the definition).
State marker
A member of the fixed semantic spine — the dev-owned vocabulary every consumer keys off *instead of* a literal status value, so a site may rename or reshape its Workflow definition states without breaking anything downstream. Permit markers (PermitMarker, ten members): WORK_MAY_PROCEED, EDITABLE, the three-way visibility partition VISIBILITY_REQUESTOR / VISIBILITY_CONTRACTOR_REVIEW / VISIBILITY_PUBLIC, CLASH_RELEVANT, EXPIRY_ALARM, TERMINAL, SPENT, WITHDRAWABLE. A state carries a subset; consumers prefer PermitMarker.X in permit_markers(status) (frontend hasMarker(p, …)) over branching on a literal status == "issued" — a handful of concrete-status branches legitimately remain. The same move as Clash category (rules filter on category, not enumerated variants). Decided 2026-06-18 (grill session). _Avoid_: status (the configurable per-site *name* — a marker is the fixed *meaning*); flag (overloaded).
Actor token
The per-transition declaration of *who may take an edge* in a Workflow definition: WorkflowEdge.actors is a set of abstract tokens from a fixed dev-authored registry of five (workflow/actors.py: requestor, coordinator, hs, issuer, receiver), satisfied by disjunction. Each token resolves to its permission band inside the resolver (e.g. permit_approve_stage1/stage2); only issuer consults Permit appointment. The 2026-06-18 design's richer gate binding — optional specific-users lists and a site-defined display label ("PtW Coordinator") — is unshipped; what shipped is the token registry. _Avoid_: role (a token resolves to a band, it is not itself a role); gate binding (the unshipped richer design — no GateBinding exists in code).
Permit event
A row in a Permit's permanent transition timeline — one per lifecycle action (created, submitted, endorsed, approved, issued, countersigned, closed, expired, withdrawn, rejected, corrections-requested, extended, reissued), plus one status-preserving overlay: handed_back (the holder's signed "work complete, area left safe" declaration — not a transition; mobile permits spec §5.3, #1583). It carries the actor (nullable — *expired* is system-driven), a timestamp, an optional note, and JSONB metadata, and is the source for the activity feed, the PDF history, and the CSV export. The cryptographic gate attestations live in the separate signature ledger; a signing event links its signature for the content-hash. Mirrors the incident timeline. _Avoid_: conflating an event with a signature (every signature has an event, but submitted / expired / withdrawn / reissued events have none); the delivery-bus *OutboxEvent* (transient and pruned — not the audit record).
Permit close-out
The formal act that ends a permit: work finished or stopped, area checked (fire watch held after hot works), tools and barriers removed, sign-off recorded. The holder's hand-back (the handed_back overlay event) may precede it — a signed "work complete, area left safe" declaration that changes no status. Expired is the alarm state — end time passed with no close-out — and still requires a close-out to reach *closed*. Unclosed permits are the red-flag surface; a site gate can hold new issues until yesterday's permits are closed. _Avoid_: expiry as a synonym for closure (the external tool conflated them).
Permit issue (walk-down)
The distinct act that makes an *approved* permit live: the issuer physically verifies controls at the workface (fresh gas test, exclusion zone up, named personnel present) and both issuer and receiver sign in-app. The issuer can refuse — bounce to corrections or reject — if conditions aren't met. _Avoid_: issue as a synonym for second approval (approval is the desk decision the night before; issue is the morning act that goes live).
Permit appointment
The per-site, optionally permit-type-scoped record naming a user as an authorised issuer — the in-app analogue of the written Authorised Person appointment in UK practice (formal, named, scoped, not portable between sites). Appointments layer on top of existing site roles rather than being a role: a zone construction manager is *both* a coordinator and an appointed issuer. Role bands give the workflow defaults (request = member+, first approval = coordinator+, second approval = hse/admin); issuing always requires an appointment. The same mechanism is how a site configures *who* acts at each gate. _Avoid_: permit-issuer as a SiteRole (one-role-per-membership can't model it); PTW-coordinator (the external tool's name for the second-approval band).
Permit signature
An authenticated in-app action: verified user identity, timestamp, the permit content as signed, and the state transition, audit-logged and rendered onto the permit document. Signatures occur at issue (issuer + receiver), close-out, and approvals. No drawn-squiggle capture — an authenticated action is stronger evidence than a wet-ink lookalike.
Permit receiver
The named site user (a member of the receiving contractor, defaulting to the requestor) who accepts the permit at issue and countersigns from their own device, confirming they understand its limits and conditions. The permit goes live only when both issuer and receiver signatures land. _Avoid_: free-text receiver names (the countersign must be attributable).
Permit board
The site-wide live coordination view of permits: what's issued and live where, what's approved for tomorrow, what's expired awaiting close-out. The board is public within the site — its whole function is the fire marshal and the neighbouring subbie seeing live hot works at a glance — even though in-flight applications (draft → approved) stay scoped to the requesting contractor plus the review band. In SimOops the board is a dock panel and the map *is* its spatial half (linked entities locate permits).
Permit band
"Which band is this permit in" has two answers, because two surfaces ask it about two different things. They are documented together here but declared apart: WalletBand in ui/src/app/domain/permit-band.ts, PermitBoardBand with the rest of the PTW wire shapes (ui/src/app/types/permit-application.types.ts, mirrored in models/_permits.py).
Close-out gate
A per-site, contractor-scoped policy: a contractor with expired-unclosed permits cannot have new permits issued (approvals proceed; going-live is held, with the offending permits listed) until they close out. The in-app form of "most sites won't issue tomorrow's permits until yesterday's are closed". Default off.
Permit reissue
The default way a permit's life is prolonged: a one-click clone with fresh dates and a reissued from link to its predecessor, walking the full chain again — including a fresh walk-down. UK practice insists on this for high-risk work because the issue act embeds physical re-verification; "extending" paper is the mechanism for skipping it.
Permit extension
A per-type capability (off for every high-risk type in the UK default set; on for General): a requested new end time approved at the second-approval gate, stretching the window without re-issue. Exists for administrative parent permits where nothing physical needs re-verifying. Child permits never outlive their parent's window. _Avoid_: extending high-risk permits (that is a reissue, by design).
Permit spatiality
Permits have no geometry of their own. The entities working under a permit (workers, plant, deliveries, areas) carry the geometry and reference the permit; a permit's spatial footprint *is* its linked entities. Spatial permit semantics flow through them: clash rules see a linked permit's type as an entity attribute (hot-works token near solvent-works token), and a permit's authorised extent, where one exists, is a linked work-area feature. The permit row keeps only a textual location and the import resolver's spawn hints. The external tool's spatial columns map onto SimOops terms with a vocabulary inversion worth pinning: their *Zone* is a SimOops Floor (building level), their *Area* is a SimOops Building or Zone, and their *Location* is free-text within. The persisted field literally named zone_raw holds a floor, not a zone. _Avoid_: permit-as-map-object (two spatial truths that can disagree — the token in the corridor under a permit that says plant room 3); reading "zone" off an imported permit as a SimOops Zone (it is the floor).
Linked / unlinked permit
Whether a Permit has any entities referencing it via the permit_id FK. A linked permit has at least one linked entity, so it has a spatial footprint (see Permit spatiality) and lights up the map under the permit lens; an unlinked permit exists but has zero linked entities — no spatial footprint yet — and is the population a coordinator works through when placing tokens. The authoritative signal is the live FK count (linked_entity_count), never a denormalised tally. This is the permit-side mirror of the token-side Permit coverage policy (a token with no permit): both ask "is the permit↔entity link present?", from opposite ends. _Avoid_: "mapped" (collides with the background site map); "active" (the live window / ActiveArea / alerts); "allocated" (resource allocation); "located" (an imported permit is already located by its text/matched-feature while still unlinked).
Permit placement
Dropping the entities that become a permit's footprint. Two distinct surfaces, easy to conflate: the apply workbench builds a *draft's* footprint *before* submit (placement there is required — a draft needs at least one linked entity to be submittable) and routes each drop through the confirm-and-link path; board placement is the rapid-fire walk that drops tokens under an already-issued Linked / unlinked permit|unlinked permit (the population is imported permits awaiting tokens). Board placement is issued-only — a draft offers "Continue application" (back to the apply workbench), never a board place affordance — and is tools-island-driven: the normal Add palette chooses the kind case-by-case, limited to the linkable map-token kinds (worker, plant, area). The map auto-focuses each permit's matched zone/building as the walk advances (viewport only — never the scrubber, see Permit spatiality). _Avoid_: placing tokens under a draft from the board (its footprint belongs in the apply workbench); "per-permit armed toolset" (placement no longer derives a tool set from the permit — the fixed armedTools are the linkable kinds and the tools-island Add palette is the chooser).
Permit window
A permit's start_at/end_at define a daily recurring work window across a date range — *"07:00–17:00 each working day from the 11th to the 17th"* — not one contiguous block running through the overnights. A six-day permit is six daily windows, not 144 unbroken hours. This is the interpretation the entity spawner already encodes (buildPermitScheduleOccurrences stamps the permit's HH:MM onto each calendar day in range), so visualisations and clash-overlap must read the window the same way. Whether a *physical hazard* (an open excavation, a created opening) persists outside the daily window is a separate, category-dependent question. _Avoid_: rendering or reasoning about a multi-day permit as a single all-day/all-span bar (its true footprint is the daily window repeated).

RAMS

RAMS document
The register identity for a contractor's RAMS: site, authoring contractor, title, reference number. Content is never decomposed (no risk-table or method-statement fields) — RAMS revisions carry the files. Permits link a RAMS document by nullable FK alongside the legacy free-text rams_reference; permit submission warns, never blocks, on an unreviewed / superseded / past-review-date RAMS. _Avoid_: rams_reference (the legacy string); attachment (the permit slot a revision file also satisfies).
RAMS revision
One immutable issued revision of a RAMS document: rev label, attached file(s), review/expiry date, and a single overall verdict — submitted → accepted | accepted_with_comments | rejected (the source's A/AC/R key; rejection sends the contractor back to upload a new revision). The current revision is derived — the latest revision whose status is an acceptance (accepted or accepted_with_comments) — never a stored flag. The linked revision's identity folds into the permit content hash at submit, so an approval attests the exact RAMS it rested on. _Avoid_: version (template vocabulary); superseded as a status (it is derived).
RAMS review lane
A per-revision, per-discipline review record — discipline from a per-site registry seeded with the AFRY five (Civils, Erection Lead, Mech/Elec Supervisor, Commissioning Manager, HSE), plus reviewer, note, date. Lanes are the evidence trail and never individually gate — the overall verdict is the revision's status (on the real review sheet most lanes are blank). _Avoid_: approval stage (permit vocabulary); reviewer band (RBAC term).
RAMS briefing signature
A signature-grade attestation that a named person was briefed on and signed a specific RAMS revision: name + company (optionally a user id), timestamp, and the revision's content hash — captured either by a supervisor on the operative's behalf or self-served through a per-revision public capture link (QR; the Observation capture link pattern). Does not mint a user identity for drivers or visiting crews. _Avoid_: sign-on sheet (the paper artefact this replaces); permit signature (transition-coupled, a different mechanism).

Site map

Site-map capture
A single georeferenced image of the site at a point in time — an aerial/drone pass or a scanned site plan — stored as a SiteMap (calibrated, COG-tiled). A site accumulates captures over its life; uploading a new one never deletes the old. Each carries a captured_at (when the survey was taken, distinct from when it was uploaded). _Avoid_: bare "capture" unqualified — it is overloaded with the export/report *screenshot* sense; and "version", which implies a strict lineage the flat capture list does not have.
Canonical site map
The one capture a site is currently running on — the shared background every member sees by default and that reports/exports bake in. Distinct from a member's local view. Decided 2026-06-09 (grill session): the active background is promoted from per-user ephemeral state to shared, coordinator-published site state (Site.active_site_map_id, broadcast over WebSocket); selecting a capture in the Site Maps panel *publishes* it (coordinator-only; members are read-only, no personal-preview tier). Every change appends to a site_map_activations log, so "which capture was active at time T" has one answer — the activation with the greatest activated_at ≤ T. A later, opt-in Phase 2 surfaces *temporal* divergence: when the scrubber sits far from the canonical capture's captured_at, a non-intrusive banner offers to jump to the capture current at that moment. See docs/decisions/2026-06-09-canonical-site-map-and-provenance.md. _Avoid_: "active map" as a per-user notion — it is now site-canonical.

Temporal

Site day
A calendar day (YYYY-MM-DD) as the site's clock reads it. The day boundary is the site's midnight — never the server's, never the viewer browser's, never the CI runner's — because "what day is it here?" is a question about the site. It is the day a Completion record keys its work_date on, the day the dashboard's selectedDate names, the day an incident_date records, and the "site-local working day" the coordinator day tour anchors to. Ratified 2026-08-10 in docs/decisions/2026-08-10-site-tz-day-contract.md, which is the source of truth; ui/CLAUDE.md → "Site-local vs viewer-local time formatting" is the working guide.
Active period
The single canonical "when does this entity exist / when is it scheduled" range. Expressed as two NOT NULL timestamps start_at / end_at on Worker, Plant, ActiveArea, and Delivery. A concrete absolute range (may span midnight). Decided 2026-05-22 (grill session): - Every primitive entity kind — including high_risk_commissioning_area ("HRCA") — uses *only* the Active period model. HRCA has no special "daily recurring window" semantic; its distinctiveness lives entirely in clash rules. - Both bounds are NOT NULL. No null = "all-day", no null = "open-ended", no sentinel dates. Every entity has a concrete range. - Delivery's scheduled_start / scheduled_end columns rename to start_at / end_at for consistency (legacy from before the model was unified). - See docs/decisions/2026-05-22-active-area-table-split.md.
Shift
A site-level configuration row defining a recurring clock-hour window (e.g. "Day shift, 06:00-18:00"). Shifts are referenced by name from Permit imports and from the dashboard scrubber to seed default Active period values when an entity is created. A shift is *not* a temporal property of an entity — entities only carry Active periods.
Schedule group
A set of entity instances generated from one recurring-schedule definition, sharing a schedule_group_id — its members are Occurrences. Spans Worker, Plant, and ActiveArea; each member carries its own Active period. The group exists while it has ≥1 member and ceases when the last is deleted (there is no separate group row).
Occurrence
A single member of a Schedule group — one entity row covering one Active period. The "All N" count in the edit/delete flow is the group's occurrence count.
Reach
When editing or deleting a member of a Schedule group, the choice of whether the change applies to this Occurrence alone or the whole group — surfaced to the user as the "This one / All N" prompt. Decided 2026-05-29 (grill session): "reach" is the canonical name for this axis; the edit/delete flow resolves a reach, then applies the operation at that reach. See docs/decisions/2026-05-29-schedule-operation-dispatch.md. _Avoid_: scope (AnalysisRegion already lists "scope" on its own _Avoid_ list — it is the loose word that concept was sharpened away from; reusing it here would overload the glossary).
Window
The half-open datetime interval [start, end) as a value object (app/db/queries.py::Window) — the one authority for temporal overlap, so the half-open law cannot be re-derived by hand with different edge inclusivity. A None bound is unbounded on that side, which is how the domain spells "all-day". Two distinct questions, two doors, deliberately not one: overlaps(other) (window-vs-window — touching at a boundary is *not* overlap, delegating to core/timeutil.py::windows_overlap, the single implementation) and contains(instant) (point-in-window — the start edge is closed, an entity has arrived at its own start_at; the end edge is open, it is already gone at its own end_at). The point reading is not expressible as a degenerate overlap: a zero-width window [T, T) overlaps nothing at all (#602). SQL renderings come from the same object (apply and the clauses seam), so the Python predicate and the query filter are one law. Adopted across the hand-rolled overlap re-rolls by epic #2645 (E1/E2 #2662, #2663; predicates via FU4 #2738, point-in-window via FU5 #2744). The frontend twin is ui/src/app/domain/entity-window.ts's entityWindowsOverlap (#2932): the same three laws — half-open, an unstated bound open-ended on that side, an unparseable bound read as unstated — asked of two placed entities. It replaced the extra.timeRange wall-clock channel, a pair of site HH:MM strings a placed entity carried *beside* its window; that channel named no instant, so it needed a midnight-wrap reading and day-edge completion of a missing bound, and it is retired end to end (readers #2932 L3–L5, storage scrubbed by migration 321). _Avoid_: inclusive range / [start, end] (the closed end double-counts a boundary instant); "touching windows overlap" (they do not); re-spelling the predicate inline (the value object is the door); timeRange (the retired wall-clock channel — a window names instants, not a time of day).

Areas and reference geometry

ActiveArea
A polygon that participates in clash detection *and* carries an Active period. Variants: exclusion, laydown, work_area, high_risk_commissioning_area (HRCA). All ActiveAreas have NOT NULL start_at / end_at. PR3 of the unified-temporal-model rollout landed 2026-05-23 — schedulable polygons now live in their own backend table active_areas (separate from Reference features), with sibling history tables active_area_versions and active_area_geometry_history. See docs/decisions/2026-05-22-active-area-table-split.md for the forking rationale.
Whole-floor area
An ActiveArea with placement_mode = 'whole_floor', created by a Floor-fill action rather than a freeform polygon draw. Its polygon is snapshotted from the building's polygon-with-holes at fill time and frozen thereafter — subsequent building footprint edits do not auto-propagate. A fill_source_revision mismatch with the building's current revision surfaces a "Footprint changed since fill" chip on the panel with a "Regenerate" action to re-sync the group's polygons explicitly. Operationally indistinguishable from a freeform area on the map — clash rules fire per-row, floor-aware rendering filters identically. The provenance lives on the panel, not on the map.
Whole-zone area
An ActiveArea with placement_mode = 'whole_zone', created by a Fill action targeting a zone rather than a freeform polygon draw. The sibling of Whole-floor area with the degenerate slice count: a zone has no floors, so one fill produces exactly one row per occurrence. Its polygon is snapshotted from the zone's geometry at fill time and frozen thereafter — later zone edits do not auto-propagate; a fill_source_revision mismatch surfaces the same stale chip + "Regenerate" action as the building case.
Fill
The umbrella action: creating whole-container ActiveArea rows by snapshotting a Reference feature's geometry, in one transaction. A building slices into floors (Floor-fill → N Whole-floor area rows); a zone is a single slice ("Fill zone…" → one Whole-zone area row per occurrence). Roads (linestrings) are not fillable — no interior to snapshot — and neither are level-less buildings (the rejected third tier). One container per fill action: filling three zones is three actions with three groups. Every fill mints a Fill group, down to size 1.
Floor-fill
The building-flavoured surface of Fill: creates N Whole-floor area rows (× K recurrences) over selected floors of one building in one shot. Primary entry-point is a "Floor-fill area…" button on the building properties panel; power-user accelerator is the A → F keyboard chord (which fills whichever container is focused). Both routes land on the same modal the zone case uses, with the floor picker shown.
Fill group
The set of Whole-floor area / Whole-zone area rows produced by one Fill action, identified by a shared fill_group_id UUID. Invariant: placement_mode ∈ {whole_floor, whole_zone} IFF fill_group_id IS NOT NULL.
Reference feature
A polygon (or linestring) that's part of the site's static geometry but carries no Active period. Variants: building, zone, and road (the linestring case). After PR3 (2026-05-23) the `geometadata_features table is exclusively reference geometry — the schedulable polygons moved to active_areas. A DB-level CHECK constraint enforces the boundary: feature_type IN (unknown, building, zone, road)`.
Floor footprint
An optional per-level polygon on a building — carried inside the level entry itself (the elevation_m grow-the-dict precedent), so it versions and replays through the existing feature history with no new machinery. Sparse by design: a level without its own polygon inherits the union envelope; nothing is backfilled. Together with the per-level elevation interval this forms the implicit prism model — the deliberate ceiling of SimOops's 3D ambition (per-level 2D polygon × elevation interval; no Z coordinates, meshes, or 3D geometry types in storage). Level awareness lands at write time: placement/move derive building_feature_id + building_level from refined (point, level-polygon) containment — auto-set, overridable — while the clash engine stays FK-based and level polygons never enter predicates. Overlapping envelopes resolve by refinement first (level containment, then smallest area), with the reference-feature Disambiguation picker for genuinely ambiguous picks. Decided 2026-07-06 (grill session). See docs/decisions/2026-07-06-per-floor-building-footprints.md. _Avoid_: level geometry (ambiguous with entity building_level); floor plan (the per-level interior drawing — a different system, see Floor plan); 3D model (explicitly ruled out).
Floor plan
The per-(building × level) interior drawing that renders while Floor focus is active — backed by exactly one of two artifact kinds: a raster COG (image/GeoTIFF uploads, or DXF imports taking the raster fallback) or a vector artifact (the default for DXF imports: an immutable per-level GeoJSON of the drawing's linework, text, and hatch fills). Both artifact URLs are resolved at-time off the FloorPlanVersion chain. One plan per building level, whichever the kind. Decided 2026-07-13 (grill session). See docs/decisions/2026-07-13-vector-floor-plans.md. _Avoid_: "vector layer" (collides with reference layers and MapLibre style layers — say *vector-backed floor plan* or *plan linework*); "drawing" (the DXF is the *source*; the floor plan is the derived artifact).
FloorPlanVersion
The third member of the version substrate, joining FeatureVersion and ActiveAreaVersion: a row owns the half-open system-time interval [valid_from, valid_to), the chain is gapless and non-overlapping, and db/queries.as_of is the one predicate that reads it. It replaced an audit-log scan — "which COG was authoritative at T" used to mean finding the latest entity_type='floor_plan' row ≤ T and reading a *snapshot dict key* out of it. Three things were wrong with that and none had a test that could see them: audit pruning truncates the history the tile proxy serves from; a rename inside the snapshot builder turns every historical answer into None with no type error anywhere; and the three distinct "no URL" outcomes were one indistinguishable None. They are now three named states — before_creation (no row in force: the plan did not exist yet), deleted (the row in force is a delete), unprocessed (a row was in force but its artifact column is NULL — the COG or linework had not been built, or the build failed) — and the tile proxy has a different right answer for each. Unlike the other two families, a create row is NOT epoch-anchored: a newly drawn feature should appear at every scrubber position including the past, but revision mode asks *what the building looked like then*, and a plan uploaded today did not exist yesterday. Added 2026-08-20 (epic #2792 G5, migration 315 + a one-shot backfill). _Avoid_: "plan history" (the audit trail this replaced); Floor plan (the current row, not the chain).
DxfImportLaunch
Starting a DXF import is not one call, it is a short saga whose steps have to happen in one order and whose middle can fail: validate the options and assignments → spool and parse the upload (a cheap reject before Redis or S3 are touched) → claim the per-site import lock → seed the Redis progress entry → stage the drawing to S3 under its per-render-mode key scheme → enqueue the worker job — and if any of the last three raises, release the lock, or the site stays blocked. The ordering *is* the correctness content: the lock is claimed before anything is staged because the thing being prevented is two multi-minute render jobs racing over the same buildings, and the guard is only worth having at enqueue time, before the duplicate becomes a job. Progress is seeded before the enqueue because the modal starts polling the instant the 202 lands, and per-building failures must have somewhere to be counted. The compensating release exists because from the claim onwards the *worker* is what releases the lock — if no worker ever runs, nobody does, and the site is blocked until the TTL expires. It is a function (services/geometadata/dxf_import_launch.py), not a 215-line route handler, so it is callable from somewhere that is not a request; it raises DomainError, and the one transport detail the wire still owes — the X-Active-Import-Session header on the 409 — rides the refusal's active_session field for the route to render. Named 2026-08-20 (epic #2792 G4). _Avoid_: "the upload endpoint" (the saga outlives the request); "job" (the enqueued render is the job; this is what gets it there).
Plan slab
The theme-coloured fill rendered under a vector-backed Floor plan's linework, clipped to the level's Floor footprint (else the building envelope) with a subtle edge lift — the "inside the building" ground that keeps interior linework from reading as marks on the site imagery. _Avoid_: "background colour" (the raster-era equivalent, baked into the COG at import; the slab is a live style layer).
Ghost linework
Floor-plan content falling outside the Floor footprint — grid lines, reference annotations — rendered muted/translucent rather than clipped, so it reads as reference floating over the site, not as geometry claiming the ground. Split from inside linework geometrically at extraction (footprint intersection), never by DXF layer-name convention. _Avoid_: "clipped"/"cropped" (the raster-era cutline truncated this content; the vector path deliberately preserves it).
Schedulable
Retired 2026-05-23 (PR3). Was a deprecated UI gate (isSchedulableType) listing exclusion/laydown/work_area as types that went through the permit-driven, datetime-bound creation flow. After PR3 every ActiveArea type is schedulable by virtue of living in active_areas (which enforces NOT NULL temporal bounds at the column level); the three-type gate is gone. A local const of the same name survives in the area-creation workflow, now listing all four types (HRCA included) to branch the create path, with a standing TODO to fold it into a registry query.
Geometry cut
Subtracting a shape from an ActiveArea or Reference feature's polygon, leaving a hole (donut) or a reduced outline — recorded as a first-class operation (before/after snapshots, provenance, undo-by-restore) in the per-family geometry history. Two flows share one seam: the automatic feature-vs-feature cut (a building cuts zones/laydowns per the LayerType rule matrix — shipped) and the freeform cut (the user draws an ad-hoc cutter against a selected polygon; the cutter geometry is stored inline on the history row, gated by entity_edit, never by the rule matrix). Decided 2026-07-06 (grill session): a cut is also a *versioned event* — it writes the same temporal version rows as any other geometry update, so the scrubber and at-time reconstruction see it; every reshape path (vertex edit, PATCH, drag, undo) must carry interior rings through rather than collapsing them; cut-undo is a command in the main per-kind undo stack, with redo. See docs/decisions/2026-07-06-freeform-hole-cutting.md. _Avoid_: hole tool (the cut is the operation; a hole is one outcome); clip/boolean subtract (GIS jargon); erase (implies deletion of the entity).

Reports

ReportScene
A single clash-focused frame in a safety report — a geographic bounds (optionally a rotation-aware polygon + draw-time view), the entities spatially inside it, and a clash summary. The unit a coordinator accepts/rejects in the report wizard; each accepted scene is captured to one headline image. A scene *projects to* an AnalysisRegion but is not one. _Avoid_: snip (that's the capture mechanic), cluster (the pre-acceptance proximity grouping, not the accepted frame).
Site overview
The whole-site context image in a report — one full-page map of the entire site. Distinct from the per-scene minimap inset (the small locator thumbnail composited into each ReportScene image) and from a ReportScene image itself.
Evening works boundary
The site-local time of day from which the daily report's Afternoon / Evening Works section lists placed work; declared per site (Site.evening_works_from), default 17:00. The window it opens is [D @ evening_works_from, D+1 @ 00:00) site-local — the end is site midnight, not a shift's end — and "active" in it means Window overlap, so a day task running into the evening appears. Site-wide: one site, one boundary, no per-report override. Declared, never derived — a site that moves its back shift to 16:00 moves this knob too (the diary's R12 ruling applied to the report); the platform holds no back shift as data. Edited on Settings ▸ Site ▸ Shifts, beside the shift rows. _Avoid_: back shift (a named Shift row, not this), cutoff, 17:00 (the default, not the definition).
Weather summary
The record form of a report's weather: five hour-stamped cells over the report session's window — temp high @ HH:00 · temp low @ HH:00 · max gust @ HH:00 · max sustained wind @ HH:00 + Beaufort · precipitation total (mm). It sits above the retained weather ribbon on both the on-screen preview and the PDF/DOCX export. The ribbon stays the *shape-of-the-day glance*; the summary is what a reader copies into a claim.

Analysis

Analysis tool
A *kind of computation and presentation* run over an AnalysisRegion. The founding tool is Region evolution — the ChangePointStream stepper/list. **The Analysis *suite* (the Y dock panel that hosted the tools as tabs) dissolved 2026-08-12** (wayfinder map #2379, docs/decisions/2026-08-12-dock-domain-homes.md): Region evolution and the planner view live in the Work home — since 2026-09-01 as the Reality tab and its Place drill (the Region-evolution tab retired, fold ADR 2026-09-01-region-evolution-folds-into-reality.md), Evidence and Lost time in the Commercial home; the tool concept survives the move, the constructor *grid* did not. A region-based tool consumes the same region a Constructor door produces but renders differently and owns its own controls (the look-forward/look-back *horizon* slider belongs to the tool's Changes here host only — the Place drill inherits Reality's window); the Planner (Reality) and Evidence surfaces embed their own views rather than a constructor-built region. _Avoid_: mode (overloaded with Interaction mode and the create/select keyboard modes), analysis type (vague), constructor (that is the region-selection axis, not the computation).
Constructor
The UI affordance that *builds* an AnalysisRegion — how the user chooses the spatial scope. Amended 2026-09-01 (fold ADR 2026-09-01-region-evolution-folds-into-reality.md D6): the constructor *grid* retired — live regions now arise from doors (a Reality place row → Place drill; a right-clicked building / zone / entity → Changes here), and only *Edit knobs* survives, on the neighbourhood region. The anchor kinds that remain: scene_box (the report wizard's per-scene projection — never a live constructor), entity_neighbourhood (anchor on an entity + knobs), building (a building + floor(s)), zone (a zone feature); freehand_polygon is retired end-to-end. All but entity_neighbourhood differ *only* in how the polygon_wgs84 + level_set are obtained; each resolves to that shared shape and the AnalysisEngine is identical across them. The anchor_kind discriminant records provenance for display/audit, not engine behaviour. _Avoid_: tool (the computation axis), anchor (fits entity_neighbourhood specifically — a drawn box has no anchor), selector (generic).
AnalysisRegion
A scoping value: *which entities and which time window* the AnalysisEngine should consider. A tagged union over anchor kinds — scene_box | entity_neighbourhood | building | zone (freehand_polygon retired 2026-09-01 with the constructor grid) — each variant carries the inputs its anchor needs. Every variant carries a time_window (Active period applied to the analysis: forward_days/backward_days relative to the scrubber's viewDateTime) and the polygon-plus-level-set that the engine ultimately queries against.
AnalysisEngine
The pure function that takes an AnalysisRegion and produces a ChangePointStream for the region's time window. Uniform on the region's polygon, not polymorphic on anchor kind: every Constructor resolves to a polygon_wgs84 + level\_set on the frontend (including entity_neighbourhood, whose knobs are resolved via turf.js), so the engine always computes the same thing — "entity-id set inside polygon × level\_set changes". The anchor_kind is carried for provenance/display only; the engine does not branch on it.
ChangePoint
A single unit of inflection emitted by the AnalysisEngine. Carries:
ChangePointStream
The ordered list of ChangePoints returned by the AnalysisEngine for one AnalysisRegion over its time window. Always anchored at the scrubber's viewDateTime; emits points within [viewDateTime - backward_days, viewDateTime + forward_days]. The stream is computed on demand — no precomputed materialised view — and cached by (region-hash, viewDateTime, anchor_id) only within the panel session. _Avoid_: timeline (overloaded with gantt timeline, audit-log timeline, scrubber timeline), inflection stream (jargon), change-feed (overloaded with WebSocket "feed" terminology).
Stepper
The UI control inside the Region-evolution panel (the Work home's third tab, also reachable from the map's right-click *"scheduled changes here"* door) that drives sequential navigation through a ChangePointStream. "Next" / "Previous" buttons (plus arrow-key chords when the panel is focused) advance the scrubber's viewDateTime to the next/previous ChangePoint and expose the delta summary in a banner. The same panel surfaces the stream as a list so the coordinator can jump to any ChangePoint directly; sequential and jump-to are alternate projections of one stream.
Region evolution
The look-forward/look-back Analysis tool — it walks a ChangePointStream over an AnalysisRegion. One tool, three *surfaces* — live, with two hosts (amended 2026-09-01, ADR docs/decisions/2026-09-01-region-evolution-folds-into-reality.md): the Place drill of the Work home's Reality tab (planner_view) and the map door's transient Changes here view (entity.view) — no longer a tab; and in-report as a section. Live regions arise from doors (a place row; a right-clicked building, zone or entity), never from a constructor grid. The report section consumes a ChangePointStream for a ReportScene's projected AnalysisRegion and renders each ChangePoint as a row in a per-region block; coordinator ticks which rows should be captured as a full map screenshot at export time. Unticked rows render as table-only entries (timestamp, delta summary, clash counts). The lookahead is opt-in per scene — adding a scene does not implicitly enable a Region evolution block for it.
Changes here
The transient, tab-less body of the Work home rail that the map's right-click *"scheduled changes here"* door lands on (entity.view): the tab bar renders with no tab active, the body is the Region evolution Stepper under Changes here · <region label> with its own look-window slider and a ✕. Closing it (✕ / Escape) returns you to where you were — the door records {railWasOpen, previousTab} — and clears the region. Door and Place drill share one store and replace each other: one region under examination at a time. Width key work:changes-here. Decided 2026-09-01 (#3606). _Avoid_: Region-evolution tab (it is not a tab — hopping to a tab dismisses it); breadcrumb view (rejected — the rail's shape would depend on how you arrived).

Planner analysis

Planner
The persona this capability serves: a user who owns a longer-term master schedule (WBS / activities in P6 or similar) held outside SimOps and uses SimOps as a representation of site reality to check that plan against. Whether "planner" becomes a distinct SiteRole or is served to an existing role is unresolved (RBAC TBD). _Avoid_: scheduler (overloaded with the recurring-Schedule group machinery), coordinator (a distinct in-app persona/role).
Reality (planner sense)
What Planner analysis treats as ground truth: the live, as-recorded operational state — issued Permits together with placed tokens (Workers, Plant, ActiveAreas, deliveries) — read on the valid-time axis (each entity's Active period: *when work is scheduled*, the column the scrubber walks), not the system-time edit history. It is a *proxy* for reality, not verified actuals: the rows are mutable and only as current as the site team keeps them. Because the read is valid-time, the V1 views need no new history/versioning machinery — the live DB already encodes the whole schedule, past to future. Distinct from the external master plan and from the planning-cycle plan_state='actual' fork (removed in #674). _Avoid_: actuals (implies verified/frozen — these are mutable live rows), ground truth (overclaims), plan (the opposite — the external expectation).
Plan overlay
The programme drawn on the Reality tab's contractor × place rows (planner_view): each promoted Work line lands on the row named by its own contractor_id + place/level — never by overlap with a permit band — as a *slip-tail* bar (reality bar to the plan finish, hatched amber/red tail beyond it, thin plan underbar fused beneath); each mapped-but- unpromoted staged activity draws as a Ghost bar; permit bands stay one thin lane. Slip renders twice — a per-row gutter rollup ("worst +Nd · n late") and a per-bar chip — and the choropleth gains a *behind* mode (worst slip per place) as a toggle beside the activity metric. One slip authority: the reality bar is the S6 resolve_line_window, the number is /work-lines/variance. The overlay reads persisted mappings only — a pending suggestion or Band rename never paints the map. It doubles as the promotion funnel: a place full of ghosts is the next promotion. Decided 2026-08-31/09-01 (map #3599 rulings 2, 3, 13; prototype #3600). _Avoid_: baseline overlay (the variance ADR's parked working name); compare tab (rejected — a mode noun); plan lane / plan bar for the ghost (a ghost is a *staged* activity, not a plan of record).
Ghost bar
A Plan overlay bar for a staged activity whose curation resolves a contractor and a place (via Activity mapping → Band mapping → programme default) but which nobody has promoted: dashed outline, dashed plan underbar, phys_complete_pct as a translucent plan-colour fill, in its own lane. It never carries slip or RAG — it has no reality window — and the legend line *"ghost — staged, not promoted · no slip"* is its whole disclaimer. Not drawn when the resolution order yields no contractor or no place, when the band is hidden, or for LOE / WBS-summary rows; every programme on the site ghosts, with a legend entry per programme. "Ghost" is the in-context shorthand on the overlay. Decided 2026-08-31 (map #3599 ruling 3; #3600). _Avoid_: the bare word elsewhere — "ghost" is already overloaded (the audit overlay's ghosts; Ghost linework); ghost line (it is not a Work line); projected slip on a ghost (a forecast, never drawn).
Place drill
The Region evolution stepper opened inside the Reality tab by clicking a row's place label (or its choropleth polygon): the region is the place only — whole building (level_set null), one floor on a floor-grain row, a zone's footprint — the contractor is ignored (the engine has no contractor axis). The drill inherits Reality's window (now- anchored, horizon × 7 days forward, 5 back; no slider); stepping moves the scrubber while the matrix stays put and its focused-day highlight follows the scrubber. Escape closes the drill, then the rail. Rides planner_view. Decided 2026-09-01 (#3606). _Avoid_: Region-evolution tab (retired); contractor drill (a later chip, not this).
Two densities
The rule that Board, Programme and Reality rows render from one template at two densities: a table at Expand width, two-line rows at rail width — L1 identity + state (code · name · status badge · slip chip), L2 muted context (contractor · place · window · tokens) — via a single container-query token at 640px; no horizontal scroll on the rail, nothing dropped, everything stacked. Board verbs leave the rail row for its row-expand; Programme keeps *Promote* / *↗ WL* inline; the band header folds to a mapping summary line. Decided 2026-08-31 (map #3599 ruling 10; prototype #3601). _Avoid_: compact mode (a second template); strip rows for Board / Programme (rejected — heterogeneous windows break a shared % scale).
Critical (driving path)
A staged activity, or the line promoted from it, whose P6 driving_path_flag is set — staged as a column (2026-09-01), shown as a *critical* badge and a *critical only* grid chip. "Only the critical chains get lines" is thereby a filter, not a rule of thumb. Distinct from the manual Critical / Sub Critical activity code (a chip like any other) and from SimOps computing any float of its own (it does not — CPM stays out of scope). _Avoid_: critical path (P6 computes it; SimOps only reads the flag).
Activity (planner sense)
The atom the planner reads and compares: an issued Permit seen as a unit of scheduled work — the closest SimOps analogue of a P6 activity, since it alone carries a structured *who* (Contractor), *what* (permit categories), *when* (Permit window) and *where* (matched Named place + linked tokens). Placed tokens are its manifestation: they supply the hard location (their building/zone FKs) and the human detail (label, worker_type) the permit's own free-text location lacks. Tokens carrying no permit surface as a separate "unpermitted" bucket. _Avoid_: task (overloaded with the Task action item), WBS element (the external plan's unit), activity-as-a-new-entity (it is the permit under a planner lens, not a new row).
Named place
The unified location axis for Planner analysis: a building or a zone (the site's two non-temporal named Reference feature kinds), treated as one "place" axis because a planner reasons about both as named locations ("Electrical Building", "Turbine Hall") whether or not SimOps gives them floors. Floor (a building's building_level) is a first-class drill-down — "in the Turbine Hall" and "on Level 2" are both valid queries. A place is attributed from the tokens' hard FKs (building_feature_id, zone_id), with a permit's free-text location as fallback. Vocabulary inversion (see Permit spatiality): the planner's external terms are the *inverse* of SimOps's — their *Zone* = our floor, their *Area* = our building/zone. V1 renders SimOps's terms; a translation adapter is deferred. _Avoid_: zone (one member, not the axis), area (overloaded with ActiveArea), location (too generic).
Planner timeline
The spine of Planner analysis (working name): a lookahead-shaped activity matrix — rows grouped contractor-first (Named place as filter / sub-group, pivotable to place-first), columns a near-term window, cells the Activity (planner sense) bars (the Permit window daily-recurring semantics preserved). Built to structurally mirror the planner's P6 Gantt so the two read side by side. Decided 2026-06-18 (grill session): - A dedicated Planner surface, not a tool inside the coordinator Analysis panel (see Analysis tool): the postures differ (broad, contractor-organised, lived-in vs region-scoped, ephemeral, scrubber-stepped). It reuses the AnalysisEngine + AnalysisRegion / Constructor model and the permit-ribbon *math* (permitDailyWindows), rendering fresh — the permit-board ribbon's own today | tomorrow × intraday-hour view is the wrong granularity to stretch. - Lookahead time model: default ~14-day horizon (the 2-week short-interval lookahead) + a ~5-day backward tail (so overstaying — "still here 3 days after they should have moved" — is visible), expandable to ~6 weeks; day columns, week columns when stretched. - Now-anchored and scrubber-decoupled: its own anchor-date control; it does not re-window when the map scrubber time-travels; selecting an activity/cell drives the scrubber one-way for spatial inspection. (A Now-anchored surface in spirit.) _Avoid_: Gantt (the shape, not the thing), Lookahead (as the abstraction title — the surface is bidirectional via the backward tail; "lookahead" is fine as a user-facing *label*, echoing the Region evolution avoid-note), permit board (the operational Permit board ribbon — a different surface).
Congestion (trade-stacking)
The headline spatial-temporal metric of Planner analysis: the degree to which a Named place is over-subscribed over an interval — the count of distinct Contractors (and/or total token footprint against the place's capacity) co-located there. It is the insight SimOps can surface that the planner's external schedule cannot: P6 encodes activity *logic* but not *spatial* contention, while SimOps is spatial-native. Drives the aggregate mode of the place choropleth (see Planner timeline). (The designed-for congestion-over-time companion ribbon has not shipped.) _Avoid_: clash (the Clash engine's proximity/rule conflict — congestion is a planning density, not a safety clash), crowding (informal), density (the continuous-heatmap *rendering*, not the place-level metric).
Resource loading
The reality-side per-contractor presence counts across the Planner timeline window. What ships is deliberately modest: the spine band's occupancy badge and the per-activity Nw · Mp marker counts (whose own tooltip says "markers placed, not a headcount" — SimOps counts placed markers, never people). The designed-for histogram ribbon beneath the timeline (the WeatherRibbon pattern reused) has not shipped. Purely descriptive — no planned-vs-actual baseline exists in-app (see Reality (planner sense)). _Avoid_: S-curve (cumulative-progress — not computable without a baseline), utilisation (implies a capacity denominator SimOps does not hold).
Dwell time
The consecutive duration a Contractor has occupied / is scheduled in a Named place — the metric that operationalises *overstay* detection inside the present-reality constraint: the surface shows "in the Electrical Building N days and counting, M scheduled" (the Planner timeline's backward tail supplies the "and counting") and the planner judges whether that is wrong. Surfaced as a per-Activity (planner sense)|activity badge plus a sortable column. The app never asserts lateness; it supplies the number. _Avoid_: duration (the Active period length of one entity — dwell aggregates presence across a place), age (overloaded).

Site provisioning

Create-Site wizard
The in-app flow that provisions a new Site and its operable baseline. Superadmin-only today (architected so the gate can widen later). Builds progressively: step 1 creates the bare Site row + the creator's verified admin SiteMembership, and each later step drives the *existing* per-site operations against that real site — shifts, a first Contractor, the active rule profile, the background Site map, and optional invites.

Site organisation — contractors

Contractor
A team operating on a Site; its members own the workers, plant, deliveries, areas, and permits tagged to it. A Contractor belongs to exactly one Site. Carries aliases ("also known as" — CC08, AFW; added 2026-09-01, #3611), whole-phrase matched by every name→contractor matcher (programme Code roles, permit import) — the twin of a Reference feature's place aliases; site vocabulary, never per-programme config.
Main contractor
The single principal Contractor of a Site, designated per-site and opt-in — a Site may have none. When set it is the root of the contractors-panel hierarchy and the parent of every otherwise-unparented Contractor on the Site.
Contractor oversight
A supervisor → subordinate edge between two Contractors on a Site: the supervisor's members gain CRUD parity over the subordinate's entities. The edge does double duty — it is *both* the authorisation grant *and* the panel hierarchy. "Appears under X" ≡ "X can edit it".
Transitive authority
Whether Contractor oversight authority cascades down the subtree. When a Site's oversight_transitive flag is on (the default), a supervisor's members reach *every* descendant — so the Main contractor reaches every Contractor on the Site; when off, authority is one-hop (direct subordinates only).
Subordinate / supervisor
The two ends of a Contractor oversight edge — the subordinate is nested beneath, the supervisor sits above. In UI prose "responsible" is an acceptable synonym for supervisor. _Avoid_: parent/child (reserve those for generic tree mechanics).
Oversight relationship
A viewer-relative classification of a clash-token (worker, plant, or ActiveArea — anything entity_kind: token) by where its owning Contractor sits relative to the viewing user's Contractor: - mine — the token's Contractor *is* the viewer's; - subordinate — the token's Contractor is in the viewer's canonical supervised set (the Subordinate / supervisor subtree, honouring the Site's Transitive authority flag — *not* an unconditional descendant walk); - other — everything else, deliberately absorbing supervisors, unrelated Contractors, and unassigned (no-Contractor) tokens alike.
Relevant clash
A viewer-relative classification of a Clash: a clash is relevant to a viewing user when at least one participating entity is owned by a Contractor in the viewer's responsibility set — their own Contractor plus its Subordinate / supervisor subtree (honouring the Site's Transitive authority flag); equivalently, any participant whose Oversight relationship is mine or subordinate. A clash whose participants are *all* other is non-relevant.
Affiliated coordinator
A user whose Site membership carries a coordinator/admin role *and* a contractor_id. They are vantaged (their Contractor anchors an Oversight relationship vantage), so they receive the Relevant/Other panel grouping like any contractor — but their Clash stance authority follows their role (coordinator = site-wide), not their Contractor scope. Clash grouping (vantage) and stance authority (role) are deliberately decoupled; an affiliated coordinator is the case that separates them. Decided 2026-06-05 (grill session); see the ADR. _Avoid_: conflating "what I'm responsible for" (vantage/grouping) with "what I may act on" (role/authority) — they are independent axes.

Demo sandbox

Demo Template
The single, PII-free snapshot of the real SCEC site from which every Demo World is cloned — produced once by Scrub (demo sense)|scrubbing a real SCEC extract, and refreshed only when the schema or seed data changes. _Avoid_: "golden copy", "demo seed" — it is not the synthetic site built by seed.py; it is a scrubbed clone of the *real* site.
Demo World
An ephemeral, isolated, throwaway instance of SimOps preloaded with the Demo Template, handed to one prospect to explore freely; independent from every other Demo World and discarded when it goes idle or ages out. _Avoid_: "demo session" (collides with the auth/HTTP sense of session and the unrelated ReportSession entity); "demo site" (a Demo World *contains* a site, but is the whole isolated instance, not just the Site row).
Scrub (demo sense)
The one-time transformation that removes all PII and commercially-sensitive content from a real SCEC extract to produce the Demo Template — by dropping, replacing-with-synthetic, or redacting each field per a scrub map. _Avoid_: "anonymise" — scrubbing also drops commercial IP and may relocate identifying geometry, not only personal identifiers.
Synthetic floor plan
A procedurally-generated, function-themed building-interior raster that stands in for a real Floor focus|floor's plan in the Demo Template, so the demo never exposes a client's actual architectural drawings — one per building floor (and the storage zone), georeferenced to the real footprint and rendered in a shared paper-and-ink CAD style. Amended 2026-07-13 (grill session): with vector-backed Floor plans, the stand-in matches the real plan's kind — a synthetic vector twin (the same procedural linework emitted as GeoJSON) for vector-backed plans, the raster for raster-backed ones. _Avoid_: "fake plan", "placeholder" — it is a deliberate, footprint-accurate stand-in, not a blank or a generic filler.
Character
One of the named, fictional people baked into the Demo Template as the demo's cast — each holding a genuine role-scoped site membership (coordinator, HSE manager, contractor supervisor, QS, site manager, client), except the anonymous operative, who deliberately has no account. Every Tour is experienced *as* a Character; the visitor can switch between them; workflow counterparties are other Characters. Fiction by construction — injected after scrub verification, never derived from a real person. _Avoid_: "persona" (this glossary uses persona for the abstract user archetype a capability serves; a Character is a concrete demo identity), "demo user" (ambiguous with the visiting prospect), "actor" (reserved for Co-actor).
Co-actor
A Character other than the one the visitor is viewing as, performing its side of a workflow while the visitor watches the consequence arrive live — the contractor's photo close-out landing in the HSE review queue. How one visitor experiences a two-sided loop without switching seats mid-story. Co-actor actions are real domain writes in the visitor's own Demo World. _Avoid_: "bot", "ghost", "simulation" (nothing is mocked — the writes are real).
Re-anchor (demo sense)
The Demo Template transformation that shifts every timestamp in the scrubbed extract so the snapshot's "present" lands on today, preserving site-local wall-clock times — making a Demo World's data genuinely current instead of frozen at the extraction date. _Avoid_: "rebase" (collides with git and with the Alembic migration-chain rebaseline), "date shift" (imprecise about the wall-clock-preserving contract).
Fabricated history
Deterministic fictional records — stoppages, ledger events, inspection runs, review chains, investigation timelines — written into the Demo Template after scrub verification, giving the record classes the scrub necessarily drops a believable past. The substrate every history-selling demonstration stands on. _Avoid_: "synthetic data" (too broad — synthetic floor plans are a different thing), "backfill" (collides with data-migration backfills).

Learning surfaces — tutorial & onboarding

Lesson
The atom of teaching: everything we teach about *one addressable thing* — a tool (Worker, Box-select), a panel (Noticeboard, the permits board), or a concept that has no single button (clash detection, the timeline scrubber, Floor focus). Keyed to that thing's id in a tool/feature registry. Concept-Lessons (no backing control to point at) are first-class — the reference library needs a "what is a clash?" entry even though there is no clash *button*. A Lesson holds up to two *renderings* of the same teaching: a Clip and a Recipe. Authored once; composed by many Tours and by Contextual help. _Avoid_: "module" (overloaded — names the unit but also any code module), "topic", "card" (Briefing already owns "card"), "step" (a step is one beat *inside* a Recipe, not the Lesson).
Clip
The reference rendering of a Lesson: a short, authored, autoplaying screen-capture (+ blurb) that shows the thing in ~10 seconds. The *pull* artifact — what Contextual help surfaces, and the medium of the per-tool reference library. Produced by the docs-site capture pipeline (provenance- hashed, staleness-flagged). _Avoid_: "video" (a Clip is a short silent loop, not a narrated film), "screencast" (implies length + voiceover).
Recipe
The tour rendering of a Lesson: the live step(s) that teach the thing by making the user *operate the real app* — what to spotlight, what outcome to wait for, what consequence to point at once it happens. The *push* artifact — what a Tour sequences. A Recipe drives and observes the live app in the user's own Demo World (or real site); it renders nothing pre-recorded. _Avoid_: "script" (overloaded with JS), "flow" (collides with the capture pipeline's Playwright flows), "steps" alone (a Recipe is the named bundle).
Reveal beat / Do beat / Nav beat
The kinds of step a Recipe is built from (BeatKind = 'reveal' | 'do' | 'nav'). A reveal beat — the app points the user at something that has appeared (the now-red token after a clash, the clash row in the Noticeboard) or narrates over a dim, while the user watches. A do beat — control passes to the user; the beat completes when the user *achieves the beat's outcome*, however they get there (gating is on observable domain state — "a clash now exists", "a pin is selected" — not on a prescribed click path). A nav beat — a click-gated navigation step that moves the user to the surface the next beat needs. A Tour interleaves them. _Avoid_: "show step / action step" (weaker), "task" (Task is the surfacing concept), "interaction" (every beat is an interaction of some kind).
Tour
An ordered, audience-targeted sequence of Lessons plus connective narration — a *playlist*. Owns no teaching content of its own; it sequences and frames Lessons' Recipes. The prospect Tour is one ("spatial basics → move a worker → watch it clash → wind the clock → read the noticeboard"); a "permits deep-dive" is another; a role-specific customer-onboarding Tour is another. The same Lesson may appear in several Tours. _Avoid_: "walkthrough", "journey" (buzzword), "guide" (the docs-site Guide zone owns that word), "wizard" (implies a one-off form-completion flow).
Contextual help
The pull surface: surfaces a *single* Lesson's Clip (+ blurb) right where its tool lives, on demand — reached by a deliberate affordance on the tool itself. No sequence, no narration, no app-driving — just "what is this thing", summoned when the user wonders. The reference half of the system; the Tour is the guided half. _Avoid_: "tooltip" (a tooltip is a one-line label; Contextual help opens a Clip), "help mode", "inspector".

Role handbooks — permission-derived induction

Handbook
A per-role induction document, derived from the permission spine rather than hand-maintained: one book per distinct ROLE_PERMISSIONS frozenset (viewer and client merge into the read-only book while their sets stay identical). A book's reachable-feature skeleton is generated from the Permission model and stitched into a workflow narrative by authored connective tissue, so a permission change mechanically updates the book and flags the stitching for review. Public: a handbook describes the product's role model, nothing site-specific. _Avoid_: "role guide" (the Guide zone is the end-user help; a handbook is the role-scoped induction read), "manual".
Band
The coarse capability class a role holds on a feature — one of view, contribute, manage, approve, in ascending order. Derived mechanically from the role's permission verbs on that feature (the highest verb class held), it is the one-line summary a Handbook shows against each feature; the full all-roles × all-verbs matrix is the internal reference on the feature page. A band change is pinned and queued for stitching review, never silently re-pinned. _Avoid_: "level", "tier" (a tier is the documentation quality contract, a different axis), "role".
Placement
The unit of authorship in a Handbook: a ::: feature <row> block whose head the generator fills (feature name + Band line + link) and whose body is the authored connective tissue. Its position in the section tree *is* the workflow ordering. A placement naming a feature the book's role cannot reach breaks the build; a reachable tier-1 feature left unplaced reddens the coverage report; a reachable tier-2 feature left unplaced is swept into the book's "Also available to you" appendix. _Avoid_: "section", "entry" — a placement is specifically the feature block, tissue and all.
Was this page useful? Report a problem

No cookies and no third-party analytics. A verdict records this page's path — and a search that finds nothing records the words you typed — in this site's own server log.

Generated from source · SimOops living system reference