Org::JobRole
Purpose
An Org::JobRole is a clean job template at the company level — it carries the title, description, and requirements that a company reuses every time it posts a job for that role. McDonald's has one Org::JobRole for "Casual Crew" and reuses it across 30 outlets and many shifts; NTUC FairPrice has one for "Retail Assistant Dry" and reuses it across all its supermarkets.
The legacy jod_templates table tried to be this. It failed because (a) employers created a new template for every shift instead of reusing one, (b) rate variants ("Cashier - Weekend", "Cashier - PH") were smuggled into the template name, and (c) there was no DB-level uniqueness, so duplicates accumulated. Org::JobRole fixes all three: it is per (company, title, employment_type) with a unique index, rates live on Gig::PayRate instead of in the template, and a Gig::Job snapshots the template's content at create time rather than recreating it.
Org::JobRole is master data. It changes slowly, is managed by HQ, and survives operational events such as outlet closures, outlet reopenings, and individual job archival. The operational records that use the template — Gig::Job (a posting at one outlet) and Gig::PayRate (a wage rate) — both reference Org::JobRole as their company-level anchor.
The model exists for two reasons:
- Pay rate defaults at company level. Without a row that lives at
(company, role), aGig::PayRatecannot express "the default Casual Crew rate is 12 SGD per hour at every outlet, except city-centre outlets that pay 14". WithOrg::JobRole, defaults need one row, and overrides need only the rare exceptions. - Master-data lifecycle separation. Renaming a role, archiving it across the company, or rebranding it must be a single HQ action — not a 30-row update across 30 outlets.
Gig::Jobis bound to a specific outlet, so it cannot host this kind of company-level change.
See the decision note at 35-gig/notes/2026-05-07-decision-gig-pay-rates-org-roles.md for the full reasoning, including the rejected alternatives (Gig::JobRole only, using Gig::Job as the spine, adding a global Org::JobRoleCategory taxonomy, and forcing Career::Job to reference this model from day one).
Field alignment with Listings::Job
The chain Org::JobRole → Gig::Job → Listings::Job carries content forward by snapshot. To keep the projection clean, field names match where the concept matches:
Org::JobRole | Gig::Job | Listings::Job | Note |
|---|---|---|---|
title | title | title | Same name across the chain |
description | description | description | Same name across the chain |
requirements | requirements | requirements_raw | The column rename happens at projection into Listings::Job. Org::JobRole and Gig::Job keep the plain requirements name because master and operational layers do not carry the formatting concerns that the _raw convention exists for downstream. |
employment_type | (derived from role) | employment_type | Same vocabulary (:gig, :full_time, :part_time, :contract, :casual) |
Fields that exist on Listings::Job but are deliberately not on Org::JobRole:
description_summary— derive at projection time if marketplace cards need it.pay_from,pay_to,pay_type,pay_currency,is_pay_shown— pay lives onGig::PayRate, not on the role.benefits_raw,has_benefits— defer to a later iteration; not used by gig today.taxonomy_category_id— open question (see Open Questions below).
Naming
Org::JobRole, not Org::Role. The Org domain already uses the word "role" for membership roles (hq_manager, area_manager, outlet_manager) on Org::Membership. Those describe what a person can do in the system. Org::JobRole describes what kind of work a company hires for. Two different concepts; two different names. The qualifier Job matches industry HR vocabulary (Workday "Job Profile", ADP "Job Code", SAP "Job Role").
Key Concepts
Employment Type
Every Org::JobRole carries an employment_type value. All values are accepted from v1 because new company sign-ups go through the Careers product and create full-time, part-time, contract, and casual roles immediately.
| Value | Primary consumer | Notes |
|---|---|---|
:gig | Gig | Used by Gig::Job and Gig::PayRate |
:full_time | Careers | Used by Career::Job for permanent roles |
:part_time | Careers | Used by Career::Job for part-time roles |
:contract | Careers | Used by Career::Job for fixed-term contracts |
:casual | Careers | Used by Career::Job for casual / on-call employment |
The vocabulary matches Listings::Job.employment_type exactly so projections do not need a translation layer.
A company can have multiple roles with the same title but different employment types — they are independent rows. McDonald's may have one row for ("Crew", :full_time) and another for ("Casual Crew", :gig). They are operationally separate (different shift assignment, different payroll, different uniform) and the system treats them as independent records. The unique index on (org_company_id, title, employment_type) enforces this.
Master Data vs Operational Data
Org::JobRole is master data — describes the category of work. Gig::Job is operational data — describes a posting of that category at a specific outlet. Gig::PayRate is pricing — the wage for that role under specific conditions.
Master data has different mutation patterns from operational data:
| Concern | Master data (Org::JobRole) | Operational data (Gig::Job) |
|---|---|---|
| Mutation cadence | Slow — months apart | Faster — outlet-level events |
| Lifecycle event | Rebrand, retire, reorganise | Outlet open, outlet close, posting archived |
| Scope of a single edit | Whole company | One outlet |
| Survives outlet churn? | Yes | No — archived when outlet closes |
When a Gig::Job is created, the system snapshots title, description, and requirements from the linked Org::JobRole onto the Gig::Job row. This is the same denormalisation pattern Listings::Job uses for company_name and outlet_name. The snapshot makes reads fast (no extra join). The foreign key keeps the validation tight (PayRates filter by role).
Snapshot Sync
When HR renames a job role on Org::JobRole, the snapshot fields on existing Gig::Job rows are updated by a sync service. The sync runs after the transaction that updated the role. Marketplace listings (Listings::Job) are re-projected from the updated Gig::Job rows.
The snapshot fields on a Gig::Job are not editable directly. To change them, HR edits the Org::JobRole and lets the sync propagate. This keeps the company's roles consistent across all outlets.
Model Context
| Context | Details |
|---|---|
| Entity | Org::JobRole (independent — no aggregate children) |
| Layer | Organisation Structure — Master Data |
| Upstream dependencies | Org::Company (the company that owns the role) |
| Downstream dependents | Gig::Job (snapshots title, description, requirements; FK for PayRate filter), Gig::PayRate (rate cards anchored on the role) |
State Machine
Org::JobRole has a simple two-state lifecycle. There is no draft state — a role is created in :active directly.
| From | To | Trigger | Notes |
|---|---|---|---|
| (new) | active | UC-1 create | Default state on creation. The role is immediately usable for new Gig::Job postings. |
active | archived | UC-3 archive | No new Gig::Job rows can be created with this role. Existing Jobs continue. |
archived | active | UC-4 reactivate | The role becomes available for new postings again. PayRates are unchanged. |
Use Cases
| ID | Use Case | Trigger | Actor |
|---|---|---|---|
| UC-1 | Create a job role for a company | New client onboarding, or HR adds a new category of work | Admin / Employer |
| UC-2 | Edit a job role's title and description | Rebrand, typo fix, or HR clarifies the job description | Admin / Employer |
| UC-3 | Archive a job role no longer used | HR retires a role across the company | Admin / Employer |
| UC-4 | Reactivate an archived job role | HR resumes hiring for a previously retired role | Admin / Employer |
| UC-5 | View list of job roles for a company | HR managing the company's role catalogue | Employer |
| UC-6 | View job role detail with linked PayRates and active Gig::Jobs | HR reviewing how a role is configured and used | Employer |
| UC-7 | View job roles across companies | Ops reviewing role configuration | Admin |
| UC-8 | Bulk-import job roles from legacy job templates | Initial migration from the legacy jodgig system | Admin / System |
UC-1: Create a job role for a company
| Field | Details |
|---|---|
| Actor | Identities::Admin (during onboarding) or Org::Membership with role hq_manager |
| Trigger | New client onboarding, or HR adds a new category of work |
Preconditions:
Org::Companyexists.- The acting user is either an
Identities::Adminor anOrg::Membershipwith rolehq_manageron the target company.
System Behaviour:
- Actor enters the role's
title(for example, "Casual Crew"), an optionaldescription, and an optionalrequirementstext. - Actor selects the
employment_type(:gig,:full_time,:part_time,:contract, or:casual). The selection drives which downstream domain consumes the role::gigis used byGig::JobandGig::PayRate; the others are used byCareer::Job. - System creates the
Org::JobRolewithstatus: :active. - The role becomes immediately available for new
Gig::Jobpostings.
Business Rules:
- The combination
(org_company_id, title, employment_type)must be unique. Creating a duplicate is rejected with a clear message that names the conflicting employment type and title, for example: "A:gigrole called 'Casual Crew' already exists for this company." titleis required. Maximum length 100 characters.descriptionis optional. Maximum length 5000 characters.employment_typemust be one of:gig,:full_time,:part_time,:contract,:casual. Any other value is rejected.- The role is not yet usable for shift creation until at least one
Gig::PayRatewithis_default: trueexists for the(company, role)combination. This is enforced atGig::Shiftcreation time (seeGig::PayRateUC-5), not at role creation.
Postconditions:
Org::JobRoleexists withstatus: :active.- The role appears in the company's role catalogue (UC-5).
- HR can now create
Gig::PayRateentries for this role.
Open Questions:
- Should the system auto-create one default
Gig::PayRaterow when a role is created (with a placeholder rate) so that the role is immediately usable? Recommendation: do not auto-create — force the HM to think about the rate. A role without a default rate cannot be used for shifts, which is a deliberate guard.
UC-2: Edit a job role's title and description
| Field | Details |
|---|---|
| Actor | Identities::Admin or Org::Membership with role hq_manager |
| Trigger | Rebrand, typo fix, or HR clarifies the job description |
Preconditions:
Org::JobRoleexists.
System Behaviour:
- Actor modifies one or more of:
title,description,requirements. - System updates the
Org::JobRolerow. - System runs a sync job that updates the snapshot fields (
title,description,requirements) on everyGig::Joblinked to this role. - After the sync,
Listings::Jobprojections are re-rendered for anyGig::Jobthat has open shifts.
Business Rules:
- Editable fields:
title,description,requirements. - Immutable fields:
org_company_id,employment_type. Once a role is referenced by anyGig::JoborGig::PayRate, changingemployment_typewould silently break those references. - The
(org_company_id, title, employment_type)uniqueness rule is checked again — a rename that would create a duplicate is rejected. - The sync job runs asynchronously after the transaction commits. If the sync fails, the role is updated but
Gig::Jobsnapshots are stale until the sync is retried. The system shows a warning to HR if the sync has not completed within 60 seconds.
Postconditions:
Org::JobRolereflects the new values.- All
Gig::Jobrows linked to this role have their snapshot fields updated. - All
Listings::Jobprojections derived from those Jobs are up to date.
Open Questions:
- Should the sync be optional — that is, should HR be able to "freeze" a
Gig::Jobsnapshot for compliance reasons (for example, to preserve the wording on a posting that is under audit)? Recommendation: do not add a freeze flag in v1. If compliance asks for it later, add asnapshot_frozen_atcolumn onGig::Joband have the sync skip frozen rows.
UC-3: Archive a job role no longer used
| Field | Details |
|---|---|
| Actor | Identities::Admin or Org::Membership with role hq_manager |
| Trigger | HR retires a role across the company |
Preconditions:
Org::JobRoleexists withstatus: :active.
System Behaviour:
- Actor selects the role and confirms archive.
- System sets
status: :archivedon theOrg::JobRole. - The role no longer appears in the dropdown when creating a new
Gig::Job(UC-1 ofGig::Job).
Business Rules:
- Archiving does NOT affect existing
Gig::Jobrows linked to this role. They keep their snapshot fields and continue to operate. - Archiving does NOT affect existing
Gig::PayRaterows linked to this role. They remain queryable for historical reporting and for shifts created before the archive. - A
Gig::Joblinked to an archived role cannot have newGig::Shiftrows created — seeGig::ShiftUC-1, which checks both the Job's status and the role's status. - Archiving is reversible (UC-4). No data is deleted.
Postconditions:
Org::JobRole.status = :archived.- New postings cannot use this role.
- Existing postings and rate cards for this role continue to work for historical reads.
UC-4: Reactivate an archived job role
| Field | Details |
|---|---|
| Actor | Identities::Admin or Org::Membership with role hq_manager |
| Trigger | HR resumes hiring for a previously retired role |
Preconditions:
Org::JobRoleexists withstatus: :archived.
System Behaviour:
- Actor reactivates the role.
- System sets
status: :active. - The role appears again in the dropdown for new
Gig::Jobcreation.
Business Rules:
- Reactivation does not touch
Gig::PayRaterows. The pre-existing rate cards apply automatically. If the rates are stale (a year of inflation has passed), HR is responsible for editing them viaGig::PayRateUC-3. - No automatic creation of
Gig::Jobrows. HR creates the new postings as outlets need them.
Postconditions:
Org::JobRole.status = :active.- The role is usable again for new
Gig::Jobcreation.
UC-5: View list of job roles for a company
| Field | Details |
|---|---|
| Actor | Org::Membership (any role) |
| Trigger | HR managing the company's role catalogue |
Preconditions:
- The actor has a membership in the target company.
System Behaviour:
- Actor opens the Job Roles page in the Employer Dashboard.
- System displays all
Org::JobRolerows for the actor's company, showing:title,employment_type,status, count of activeGig::Jobrows linked to this role, count of activeGig::PayRaterows. - Default view shows
:activeroles. Actor can toggle to see:archivedroles.
Business Rules:
- Filterable by
employment_typeandstatus. - Sortable by
title, count of active Jobs, count of active PayRates. - All membership roles can view (read-only). Only
hq_managercan edit (UC-2), archive (UC-3), or reactivate (UC-4).
Postconditions:
- Read-only operation — no data changes.
UC-6: View job role detail with linked PayRates and active Gig::Jobs
| Field | Details |
|---|---|
| Actor | Org::Membership (any role) |
| Trigger | HR reviewing how a role is configured and used |
Preconditions:
Org::JobRoleexists in the actor's company.
System Behaviour:
- Actor selects a role from the list.
- System displays the role's detail:
title,description,requirements,employment_type,status, audit fields (created/updated by + at). - System displays the role's linked
Gig::PayRaterows, grouped by outlet (with company-wide defaults shown first). - System displays the role's linked
Gig::Jobrows, grouped by outlet, showing each Job's status and count of upcoming shifts.
Business Rules:
- The PayRate panel uses the same rate-card grid format documented in
Gig::PayRate(day_type×time_typeper outlet). - The Gig::Job panel links to each Job's detail page.
- All membership roles can view this page.
Postconditions:
- Read-only operation — no data changes.
UC-7: View job roles across companies
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Ops reviewing role configuration |
Preconditions:
- None.
System Behaviour:
- Admin navigates to the Job Roles page in the Team Portal.
- System displays all
Org::JobRolerows across all companies. Filterable by company, employment type, status. - Admin can edit, archive, or reactivate any role on behalf of an employer (during onboarding or support).
Business Rules:
- Admin actions follow the same rules as the Employer-facing UCs (UC-2, UC-3, UC-4) but bypass the membership-role check.
- Admin actions are audited via
admin_created_byandadmin_updated_bycolumns.
Postconditions:
- Read-only by default. If admin edits, see UC-2.
UC-8: Bulk-import job roles from legacy job templates
| Field | Details |
|---|---|
| Actor | Identities::Admin (initiates), System (executes) |
| Trigger | Initial migration from the legacy jodgig system |
Preconditions:
- The legacy
jodgigdata is accessible (typically via the existing Railsgig_temp_jobssync, which already runsGig::TempJobs::JobTitleNormalizerover rawjod_jobs.job_titlevalues). Geo::PublicHolidayis populated with the relevant country's holidays (SG holidays are inmigration/sg-public-holidays-2025-2026.csvready to seed).
System Behaviour:
The full migration pipeline (extract → normalise → derive day_type → consolidate rates → generate defaults → output seed CSV → import) is documented in gig/migration/migration-gig-pay-rate.md. It is the source of truth for the seeding pipeline. Summary of the import step (the part this spec owns):
- Title canonicalisation is done upstream by
Gig::TempJobs::JobTitleNormalizer(Rails). The seed CSV'srole_titleandrole_slugcolumns are already canonical when the importer reads them — no further suffix-stripping is required here. Known gap (parallel naming likeRA DryvsRetail Assistant Dry) tracked in jodapp-api #1644. - Admin review of the seed CSV is optional but recommended for low-confidence rows (rows flagged
confidence: lowormediumby the consolidation pipeline). The default flow is auto-import with a "review your auto-seeded rates" banner shown in the rate-management UI. - Import: for each row in the seed CSV, the importer:
- Finds or creates one
Org::JobRolefor(org_company_id, role_title, employment_type)withadmin_created_byset to the admin running the import. - Creates one
Gig::PayRaterow linked to thatOrg::JobRolewith(day_type, starts_at, ends_at, hourly_rate). - Honours the seed CSV's
is_defaultvalue; the Manager enforces exactly one default per(company, role).
- Finds or creates one
After import, the system runs a verification report: for each legacy slot, simulate creating a shift on the same conditions and check the resolved rate matches the legacy hourly_rate. Mismatches are flagged for manual fix.
Business Rules:
- The importer is idempotent — running it twice on the same input produces the same result. It uses upsert semantics on
(org_company_id, title, employment_type). - The importer creates
Org::JobRolerows withemployment_type: :gigonly. Other employment types are not part of the legacy data. - The legacy
created_byandupdated_bycolumns map toadmin_created_byon the new row (legacy edits were Admin-driven; the legacyusers.idis not preserved). - Each (company, role) must have at least one
is_default: truerate after import. The importer fails loudly if this invariant cannot be satisfied — for example, if a company has zero legacy templates that mapped to a sensible role.
Postconditions:
- All legacy
jod_templatesare represented asOrg::JobRole+Gig::PayRatepairs. - The verification report shows zero mismatches (or a small set of manual-fix items reviewed by an admin).
- HR can immediately use the migrated roles to create new
Gig::Jobpostings.
Open Questions:
- What happens to legacy
jod_templatesrows where the title is"Do Not Use"(NTUC has many of these)? Recommendation: skip during extract, since they are admin-flagged junk in the legacy system. Surface the count to the admin during Stage 2 so they can confirm.
Invariants
- An
Org::JobRolebelongs to exactly oneOrg::Company. Theorg_company_idis immutable after creation. - The combination
(org_company_id, title, employment_type)is unique. No two roles in the same company can share a title within the same employment type. employment_typeis one of:gig,:full_time,:part_time,:contract,:casual. All values are usable from v1.- Once an
Org::JobRoleis referenced by anyGig::JoborGig::PayRate, itsemployment_typecannot be changed. Org::JobRolerows are never hard-deleted. The end state is:archived. Archive is reversible.- An archived
Org::JobRolecannot be selected when creating a newGig::Job. ExistingGig::Jobrows linked to an archived role continue to operate. - When
title,description, orrequirementschange on anOrg::JobRole, the snapshot fields on every linkedGig::Jobare updated by an asynchronous sync that runs after the transaction commits. - Every
Org::JobRolechange (create, edit, archive, reactivate) is audited viacreated_by/updated_by(forOrg::Membershipactors) oradmin_created_by/admin_updated_by(forIdentities::Adminactors).
Model Interactions
| Related Model | Relationship | Interaction |
|---|---|---|
Org::Company | Org::JobRole belongs_to :org_company | Every role is owned by one company. The company determines which Org::Membership users can manage the role. |
Org::Membership | hq_manager membership grants edit | Only hq_manager can create, edit, archive, or reactivate. Other membership roles can view only. |
Identities::Admin | Admin can manage any role on behalf of any company | Admin actions are audited via admin_created_by / admin_updated_by. |
Gig::Job | Gig::Job belongs_to :org_job_role | When a Gig::Job is created, it snapshots title, description, requirements from Org::JobRole. The FK is preserved for filtering applicable PayRates. |
Gig::PayRate | Gig::PayRate belongs_to :org_job_role | PayRates are anchored on the role. Resolution at shift creation filters PayRates by the linked role on the Job. |
Listings::Job | Indirect — via Gig::Job snapshot | Marketplace projections read from Gig::Job snapshot fields, which are kept in sync from Org::JobRole. The marketplace search vector includes the role title. |
Career::Job | Career::Job belongs_to :org_job_role (v1 for new sign-ups) | New Career::Job postings reference Org::JobRole rows with employment_type in (:full_time, :part_time, :contract, :casual). Legacy Career::Job rows that pre-date this change continue without the FK until back-filled. See Open Question 4. |
Open Questions
- Outlet-role assignment. Should we model "this outlet uses this role" with a join table (
Org::OutletJobRoleAssignment)? Today the implicit rule is "any role can be used at any outlet, and PayRate handles per-outlet rate overrides." A join table would let HR ask "which outlets need a Bartender?" — but adds management work. Recommendation: defer until a real product requirement asks for it. - Snapshot-freeze for compliance. Should
Gig::Jobrows be able to opt out of the rename sync (UC-2) for compliance reasons? Recommendation: do not add a freeze flag in v1. AddGig::Job.snapshot_frozen_atlater if asked. taxonomy_categorieslink. The existingtaxonomy_categoriestable (used bycareers_jobs.taxonomy_category_id) could be referenced fromOrg::JobRolevia a nullabletaxonomy_category_idcolumn for cross-employment-type analytics. Recommendation: defer; revisit when product asks for the analytics rollup. The earlier "rejected option D — Org::JobRoleCategory" was based on the assumption of a new table; the existing taxonomy is a different question.- Legacy Career::Job back-fill. New Career::Job postings (created after this model ships) reference
Org::JobRoleviaorg_job_role_id. Pre-existing Career::Job rows in production do not have this FK and were created without anOrg::JobRole. Should they be back-filled (one role per distinct(company, title, employment_type)derived from history) or left without the FK? Recommendation: leave legacy rows unlinked in v1; surface as a separate migration when product asks for HQ-level analytics that require complete coverage.