Skip to main content

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:

  1. Pay rate defaults at company level. Without a row that lives at (company, role), a Gig::PayRate cannot express "the default Casual Crew rate is 12 SGD per hour at every outlet, except city-centre outlets that pay 14". With Org::JobRole, defaults need one row, and overrides need only the rare exceptions.
  2. 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::Job is 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::JobRoleGig::JobListings::JobNote
titletitletitleSame name across the chain
descriptiondescriptiondescriptionSame name across the chain
requirementsrequirementsrequirements_rawThe 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_typeSame 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 on Gig::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.

ValuePrimary consumerNotes
:gigGigUsed by Gig::Job and Gig::PayRate
:full_timeCareersUsed by Career::Job for permanent roles
:part_timeCareersUsed by Career::Job for part-time roles
:contractCareersUsed by Career::Job for fixed-term contracts
:casualCareersUsed 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:

ConcernMaster data (Org::JobRole)Operational data (Gig::Job)
Mutation cadenceSlow — months apartFaster — outlet-level events
Lifecycle eventRebrand, retire, reorganiseOutlet open, outlet close, posting archived
Scope of a single editWhole companyOne outlet
Survives outlet churn?YesNo — 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

ContextDetails
EntityOrg::JobRole (independent — no aggregate children)
LayerOrganisation Structure — Master Data
Upstream dependenciesOrg::Company (the company that owns the role)
Downstream dependentsGig::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.

FromToTriggerNotes
(new)activeUC-1 createDefault state on creation. The role is immediately usable for new Gig::Job postings.
activearchivedUC-3 archiveNo new Gig::Job rows can be created with this role. Existing Jobs continue.
archivedactiveUC-4 reactivateThe role becomes available for new postings again. PayRates are unchanged.

Use Cases

IDUse CaseTriggerActor
UC-1Create a job role for a companyNew client onboarding, or HR adds a new category of workAdmin / Employer
UC-2Edit a job role's title and descriptionRebrand, typo fix, or HR clarifies the job descriptionAdmin / Employer
UC-3Archive a job role no longer usedHR retires a role across the companyAdmin / Employer
UC-4Reactivate an archived job roleHR resumes hiring for a previously retired roleAdmin / Employer
UC-5View list of job roles for a companyHR managing the company's role catalogueEmployer
UC-6View job role detail with linked PayRates and active Gig::JobsHR reviewing how a role is configured and usedEmployer
UC-7View job roles across companiesOps reviewing role configurationAdmin
UC-8Bulk-import job roles from legacy job templatesInitial migration from the legacy jodgig systemAdmin / System

UC-1: Create a job role for a company

FieldDetails
ActorIdentities::Admin (during onboarding) or Org::Membership with role hq_manager
TriggerNew client onboarding, or HR adds a new category of work

Preconditions:

  • Org::Company exists.
  • The acting user is either an Identities::Admin or an Org::Membership with role hq_manager on the target company.

System Behaviour:

  1. Actor enters the role's title (for example, "Casual Crew"), an optional description, and an optional requirements text.
  2. Actor selects the employment_type (:gig, :full_time, :part_time, :contract, or :casual). The selection drives which downstream domain consumes the role: :gig is used by Gig::Job and Gig::PayRate; the others are used by Career::Job.
  3. System creates the Org::JobRole with status: :active.
  4. The role becomes immediately available for new Gig::Job postings.

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 :gig role called 'Casual Crew' already exists for this company."
  • title is required. Maximum length 100 characters.
  • description is optional. Maximum length 5000 characters.
  • employment_type must 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::PayRate with is_default: true exists for the (company, role) combination. This is enforced at Gig::Shift creation time (see Gig::PayRate UC-5), not at role creation.

Postconditions:

  • Org::JobRole exists with status: :active.
  • The role appears in the company's role catalogue (UC-5).
  • HR can now create Gig::PayRate entries for this role.

Open Questions:

  • Should the system auto-create one default Gig::PayRate row 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

FieldDetails
ActorIdentities::Admin or Org::Membership with role hq_manager
TriggerRebrand, typo fix, or HR clarifies the job description

Preconditions:

  • Org::JobRole exists.

System Behaviour:

  1. Actor modifies one or more of: title, description, requirements.
  2. System updates the Org::JobRole row.
  3. System runs a sync job that updates the snapshot fields (title, description, requirements) on every Gig::Job linked to this role.
  4. After the sync, Listings::Job projections are re-rendered for any Gig::Job that has open shifts.

Business Rules:

  • Editable fields: title, description, requirements.
  • Immutable fields: org_company_id, employment_type. Once a role is referenced by any Gig::Job or Gig::PayRate, changing employment_type would 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::Job snapshots 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::JobRole reflects the new values.
  • All Gig::Job rows linked to this role have their snapshot fields updated.
  • All Listings::Job projections 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::Job snapshot 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 a snapshot_frozen_at column on Gig::Job and have the sync skip frozen rows.

UC-3: Archive a job role no longer used

FieldDetails
ActorIdentities::Admin or Org::Membership with role hq_manager
TriggerHR retires a role across the company

Preconditions:

  • Org::JobRole exists with status: :active.

System Behaviour:

  1. Actor selects the role and confirms archive.
  2. System sets status: :archived on the Org::JobRole.
  3. The role no longer appears in the dropdown when creating a new Gig::Job (UC-1 of Gig::Job).

Business Rules:

  • Archiving does NOT affect existing Gig::Job rows linked to this role. They keep their snapshot fields and continue to operate.
  • Archiving does NOT affect existing Gig::PayRate rows linked to this role. They remain queryable for historical reporting and for shifts created before the archive.
  • A Gig::Job linked to an archived role cannot have new Gig::Shift rows created — see Gig::Shift UC-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

FieldDetails
ActorIdentities::Admin or Org::Membership with role hq_manager
TriggerHR resumes hiring for a previously retired role

Preconditions:

  • Org::JobRole exists with status: :archived.

System Behaviour:

  1. Actor reactivates the role.
  2. System sets status: :active.
  3. The role appears again in the dropdown for new Gig::Job creation.

Business Rules:

  • Reactivation does not touch Gig::PayRate rows. The pre-existing rate cards apply automatically. If the rates are stale (a year of inflation has passed), HR is responsible for editing them via Gig::PayRate UC-3.
  • No automatic creation of Gig::Job rows. HR creates the new postings as outlets need them.

Postconditions:

  • Org::JobRole.status = :active.
  • The role is usable again for new Gig::Job creation.

UC-5: View list of job roles for a company

FieldDetails
ActorOrg::Membership (any role)
TriggerHR managing the company's role catalogue

Preconditions:

  • The actor has a membership in the target company.

System Behaviour:

  1. Actor opens the Job Roles page in the Employer Dashboard.
  2. System displays all Org::JobRole rows for the actor's company, showing: title, employment_type, status, count of active Gig::Job rows linked to this role, count of active Gig::PayRate rows.
  3. Default view shows :active roles. Actor can toggle to see :archived roles.

Business Rules:

  • Filterable by employment_type and status.
  • Sortable by title, count of active Jobs, count of active PayRates.
  • All membership roles can view (read-only). Only hq_manager can 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

FieldDetails
ActorOrg::Membership (any role)
TriggerHR reviewing how a role is configured and used

Preconditions:

  • Org::JobRole exists in the actor's company.

System Behaviour:

  1. Actor selects a role from the list.
  2. System displays the role's detail: title, description, requirements, employment_type, status, audit fields (created/updated by + at).
  3. System displays the role's linked Gig::PayRate rows, grouped by outlet (with company-wide defaults shown first).
  4. System displays the role's linked Gig::Job rows, 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_type per 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

FieldDetails
ActorIdentities::Admin
TriggerOps reviewing role configuration

Preconditions:

  • None.

System Behaviour:

  1. Admin navigates to the Job Roles page in the Team Portal.
  2. System displays all Org::JobRole rows across all companies. Filterable by company, employment type, status.
  3. 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_by and admin_updated_by columns.

Postconditions:

  • Read-only by default. If admin edits, see UC-2.

UC-8: Bulk-import job roles from legacy job templates

FieldDetails
ActorIdentities::Admin (initiates), System (executes)
TriggerInitial migration from the legacy jodgig system

Preconditions:

  • The legacy jodgig data is accessible (typically via the existing Rails gig_temp_jobs sync, which already runs Gig::TempJobs::JobTitleNormalizer over raw jod_jobs.job_title values).
  • Geo::PublicHoliday is populated with the relevant country's holidays (SG holidays are in migration/sg-public-holidays-2025-2026.csv ready 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):

  1. Title canonicalisation is done upstream by Gig::TempJobs::JobTitleNormalizer (Rails). The seed CSV's role_title and role_slug columns are already canonical when the importer reads them — no further suffix-stripping is required here. Known gap (parallel naming like RA Dry vs Retail Assistant Dry) tracked in jodapp-api #1644.
  2. Admin review of the seed CSV is optional but recommended for low-confidence rows (rows flagged confidence: low or medium by the consolidation pipeline). The default flow is auto-import with a "review your auto-seeded rates" banner shown in the rate-management UI.
  3. Import: for each row in the seed CSV, the importer:
    • Finds or creates one Org::JobRole for (org_company_id, role_title, employment_type) with admin_created_by set to the admin running the import.
    • Creates one Gig::PayRate row linked to that Org::JobRole with (day_type, starts_at, ends_at, hourly_rate).
    • Honours the seed CSV's is_default value; the Manager enforces exactly one default per (company, role).

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::JobRole rows with employment_type: :gig only. Other employment types are not part of the legacy data.
  • The legacy created_by and updated_by columns map to admin_created_by on the new row (legacy edits were Admin-driven; the legacy users.id is not preserved).
  • Each (company, role) must have at least one is_default: true rate 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_templates are represented as Org::JobRole + Gig::PayRate pairs.
  • 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::Job postings.

Open Questions:

  • What happens to legacy jod_templates rows 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

  1. An Org::JobRole belongs to exactly one Org::Company. The org_company_id is immutable after creation.
  2. 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.
  3. employment_type is one of :gig, :full_time, :part_time, :contract, :casual. All values are usable from v1.
  4. Once an Org::JobRole is referenced by any Gig::Job or Gig::PayRate, its employment_type cannot be changed.
  5. Org::JobRole rows are never hard-deleted. The end state is :archived. Archive is reversible.
  6. An archived Org::JobRole cannot be selected when creating a new Gig::Job. Existing Gig::Job rows linked to an archived role continue to operate.
  7. When title, description, or requirements change on an Org::JobRole, the snapshot fields on every linked Gig::Job are updated by an asynchronous sync that runs after the transaction commits.
  8. Every Org::JobRole change (create, edit, archive, reactivate) is audited via created_by / updated_by (for Org::Membership actors) or admin_created_by / admin_updated_by (for Identities::Admin actors).

Model Interactions

Related ModelRelationshipInteraction
Org::CompanyOrg::JobRole belongs_to :org_companyEvery role is owned by one company. The company determines which Org::Membership users can manage the role.
Org::Membershiphq_manager membership grants editOnly hq_manager can create, edit, archive, or reactivate. Other membership roles can view only.
Identities::AdminAdmin can manage any role on behalf of any companyAdmin actions are audited via admin_created_by / admin_updated_by.
Gig::JobGig::Job belongs_to :org_job_roleWhen a Gig::Job is created, it snapshots title, description, requirements from Org::JobRole. The FK is preserved for filtering applicable PayRates.
Gig::PayRateGig::PayRate belongs_to :org_job_rolePayRates are anchored on the role. Resolution at shift creation filters PayRates by the linked role on the Job.
Listings::JobIndirect — via Gig::Job snapshotMarketplace projections read from Gig::Job snapshot fields, which are kept in sync from Org::JobRole. The marketplace search vector includes the role title.
Career::JobCareer::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

  1. 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.
  2. Snapshot-freeze for compliance. Should Gig::Job rows be able to opt out of the rename sync (UC-2) for compliance reasons? Recommendation: do not add a freeze flag in v1. Add Gig::Job.snapshot_frozen_at later if asked.
  3. taxonomy_categories link. The existing taxonomy_categories table (used by careers_jobs.taxonomy_category_id) could be referenced from Org::JobRole via a nullable taxonomy_category_id column 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.
  4. Legacy Career::Job back-fill. New Career::Job postings (created after this model ships) reference Org::JobRole via org_job_role_id. Pre-existing Career::Job rows in production do not have this FK and were created without an Org::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.