Decision: Org::JobRole as the role spine for Gig::PayRate and Gig::Job
Decided on 2026-05-07.
The decision in one sentence
We will build a new model called Org::JobRole in the Org domain. Both Gig::PayRate and Gig::Job will reference Org::JobRole. We will not use Gig::Job alone as the role spine.
A note on naming
We use Org::JobRole, not Org::Role. The word "Role" alone is ambiguous in this codebase because we already have membership roles (hq_manager, area_manager, outlet_manager) that describe what a person can do in the system. Org::JobRole describes what kind of work a company hires for. The two concepts are different and must have different names.
This naming also matches industry vocabulary. Workday, Oracle HCM, SAP SuccessFactors, ADP, and Microsoft Dynamics 365 HR all qualify the term — they call it "Job Role", "Job Profile", "Job Title", or "Job Code", never just "Role". Deputy and Quinyx (shift-management tools closer to our gig domain) use "Position" for the in-store role. We pick JobRole because it is the clearest plain-English form for our team.
The question we asked
Should we add a separate Org::JobRole table at all? Or is Gig::Job enough?
This question is important because:
- The new system avoids the legacy "one job per shift" problem. A
Gig::Jobis reused across many shifts. - A
Gig::Jobalready carriestitle,description,outlet_id, andrequirements. These are the same fields a role table would carry. - Adding
Org::JobRolemeans one more table, one more page in the Employer Dashboard, and one more model spec to write.
If Gig::Job already does most of what a role table would do, why have both?
The short answer
Gig::Job and Org::JobRole describe two different things, even though their fields look similar.
Org::JobRoleis master data. It describes what kind of work a company hires for. It does not depend on which outlet is open today.Gig::Jobis operational data. It describes a specific role at a specific outlet. It has a lifecycle that follows the outlet (active when the outlet is open, archived when the outlet closes).
These two kinds of data have different lifecycles. Mixing them in one table creates the problems we already see in the legacy system.
Two kinds of data: master vs operational
This section explains the framing in plain English. The rest of the note builds on it.
Master data describes things that are stable over time. It is managed by HQ. It changes slowly. Examples:
- The list of roles a company hires for ("Casual Crew", "Cleaner", "Cashier").
- The legal entity of the company.
- The country a company operates in.
Operational data describes things that happen day to day. It is managed by outlet managers or staff. It changes often. Examples:
- A specific gig job at a specific outlet.
- A specific shift on a specific date.
- A worker's clock-in time on that shift.
When master data and operational data live in the same table, two problems happen:
- A change that should be done once at HQ becomes 30 changes across 30 outlets.
- A change that should be local to one outlet can accidentally affect HQ data.
We have already seen these problems in the legacy job_templates table. The legacy table mixes role identity (master), rate variants (master), and per-shift overrides (operational) in one place. The result is the rate-card explosion documented in statistics/job-templates-pay-rate.md.
Three concrete examples
These examples show what happens with Gig::Job alone and with Org::JobRole added.
Example 1: McDonald's renames "Casual Crew" to "Casual Team Member"
This is a master-data change. HQ decides to rename the role. The rename should apply to all 30 outlets.
With Gig::Job alone:
- We must update 30 rows in
gig_jobs(one row per outlet). - If the script fails halfway, some outlets show the old name and some show the new name.
- The team must re-sync 30 marketplace listings.
With Org::JobRole:
- We update 1 row in
org_job_roles. - A sync job copies the new name to the snapshot fields on the 30
gig_jobsrows in one batch. - The change is consistent across all outlets.
Example 2: McDonald's closes Tampines outlet, reopens it 6 months later
This is an operational-data change. A specific outlet stops and starts again.
With Gig::Job alone:
- The Tampines
gig_jobsrow is archived when the outlet closes. - When Tampines reopens, the manager creates a new
gig_jobsrow. - The manager must retype the role title, description, and requirements. Or copy from another outlet's job, which can introduce small differences (typos, casing, wording).
With Org::JobRole:
- The Tampines
gig_jobsrow is archived. The Casual Crew row inorg_job_rolesis not affected. - When Tampines reopens, the manager picks "Casual Crew" from a dropdown.
- The new
gig_jobsrow copies its title, description, and requirements fromorg_job_rolesautomatically. - All outlets stay consistent because they share one source of truth.
Example 3: McDonald's HQ sets one default pay rate for all outlets
McDonald's HQ wants the default Casual Crew rate to be 12 SGD per hour. Five outlets in the city centre pay 14 SGD per hour. The other 25 outlets use the default.
With Gig::Job alone:
- Pay rates must live on each
Gig::Job. So we need 30 sets of pay rates, one per outlet. - When HQ raises the default from 12 to 12.50 SGD, we update 30 rows.
- When a new outlet opens, the manager must enter the pay rates from scratch.
With Org::JobRole:
- HQ creates a default pay rate at the company level:
(company=McD, role=CasualCrew, outlet=NULL, rate=12). - The five city-centre outlets get an override:
(company=McD, role=CasualCrew, outlet=CBD-X, rate=14). - Total rows: 1 default + 5 overrides = 6 rows. Not 30.
- HQ raises the default by updating 1 row. The five override outlets keep their special rate.
- A new outlet opens and inherits the default automatically. No manual entry.
Why this matters for the strategic accounts
The legacy data shows the problem clearly:
- NTUC Fairprice (Hyper) has 123 templates. The actual number of distinct roles is about 8.
- The other 115 templates are duplicates that exist only because the legacy system has no way to express "company-wide rate with a few outlet exceptions".
Strategic accounts (NTUC, McDonald's, Crystal Jade, Watami, JP Pepperdine) are exactly the customers who suffer most when master data and operational data are mixed. They have many outlets and want HQ-level control. Without Org::JobRole, these customers will keep working the same way they do today: creating duplicate jobs for every rate variation. We will have moved their problem to a new database. We will not have solved it.
What we considered and rejected
Option A — Gig::JobRole only (no Org::JobRole)
Add a new table only inside the Gig domain. Career and Workforce would build their own role tables later.
Why we rejected it:
- The product vision is one platform for HR. HR wants to define roles in one place and use them across full-time jobs (Career), gig jobs (Gig), and shift management (Workforce, planned later).
- If we put roles in Gig now, we must move them to Org later when Career or Workforce arrives. Moving roles across domains is risky because Listings::Job already references role data through denormalised fields.
- Starting in Org costs nothing extra today and avoids the move later.
Option B — Use Gig::Job as both operational data and master data
This is the option this note is mainly about. It is the strongest alternative.
Why we rejected it:
Gig::Jobis bound to one outlet by design. The role concept must exist at the company level, above outlets. There is no clean way to put company-level data on a per-outlet table.- Outlets close and reopen. Roles do not. With
Gig::Jobonly, the role concept dies and is recreated each time an outlet churns. WithOrg::JobRole, the role concept survives outlet churn. - HQ-level changes (rename, archive, analytics) become 30-row operations instead of 1-row operations.
Option C — Org::JobRole plus a separate Gig::Role
Org owns the master role list. Gig keeps its own Gig::Role for gig-specific attributes. PayRate points to Gig::Role.
Why we rejected it:
- HR managers will create roles inconsistently across two pages.
- The cross-employment-type view ("how many cashier-ish workers does this company need?") still requires a join across both tables. We are doubling the master-data surface for no clear product win.
- Doubles the seeding work and the audit surface.
Option D — Add a global taxonomy table (Org::JobRoleCategory)
A small global table ("Cashier", "Service Crew", "Waiter") above Org::JobRole to support cross-company analytics and marketplace filtering.
Why we rejected it:
- Marketplace filtering already works through
Listings::Job.search_vector(PostgreSQL full-text search) andListings::Job.taxonomy_category_name(denormalised string). No extra table is needed today. - Cross-company analytics is not a v1 requirement. If it becomes one, we can add a
categorystring column toOrg::JobRolewith one ALTER. - Building it now is paying for a problem we do not have.
Option E — Career::Job references Org::JobRole from day one
Make Career::Job point to Org::JobRole so HQ can see "cashier-ish demand" across full-time and gig.
Why we rejected it:
- Career::Job is in a different domain that is not part of the gig migration.
- McDonald's full-time "Crew" and gig "Casual Crew" are different roles by their own admission. Pointing them at the same
Org::JobRolerow does not match the business reality. - HR can still get cross-employment-type insight by joining on
Org::JobRole.titleor by adding a category later. Forcing the FK now is paying for a problem we do not have.
What we will build
Org::JobRole (new table)
A new table in the Org domain. One row per (company, title, employment_type).
Key columns:
| Column | Type | Notes |
|---|---|---|
id, uuid | bigint, string | standard |
org_company_id | bigint, FK | which company owns this role |
title | string | role name (for example, "Casual Crew"). Snapshotted to gig_jobs.title and listings_jobs.title. |
description | text | what the role does. Snapshotted to gig_jobs.description and listings_jobs.description. |
requirements | text | optional. Snapshotted to gig_jobs.requirements; projects to listings_jobs.requirements_raw at marketplace time. |
employment_type | string | All values usable in v1: :gig (used by Gig domain), :full_time / :part_time / :contract / :casual (used by Careers — new sign-ups need all four from day one). Same vocabulary as Listings::Job.employment_type. |
status | string | :active or :archived. The :archived value is the terminal soft-archive state — no separate is_deleted / deleted_at columns needed because the status enum already carries that meaning. |
created_by, updated_by | bigint, FK | org_memberships audit |
admin_created_by, admin_updated_by | bigint, FK | identities_admins audit |
created_at, updated_at | datetime | standard |
Unique index on (org_company_id, title, employment_type).
No slug column. The Job Roles page lives in the Employer Dashboard as an internal admin route — id is enough for navigation, uuid is enough for cross-system references, and there is no public URL for a role.
Each company can have many roles. McDonald's might have one row for "Casual Crew" with employment_type = :gig and another row for "Crew" with employment_type = :full_time — both from v1, since Careers and Gig are in scope from day one. The two rows are independent: different downstream domains consume them, different operational rules apply.
Gig::PayRate (existing spec, no rename)
The existing PayRate spec already references Org::JobRole and uses org_job_role_id as the foreign key column. No rename is needed. What changes:
- The schema gap noted in the existing spec ("
Org::JobRolemodel spec does not exist yet") is now closed by the newOrg::JobRolemodel. - The resolution chain stays the same:
- Look for an exact match at outlet level:
(company, outlet, role, day_type, time_type). - Fall back to company level:
(company, outlet=NULL, role, day_type, time_type). - Fall back to the default rate for
(company, role).
- Look for an exact match at outlet level:
Gig::Job (existing spec, one addition)
The existing Job spec already references Org::JobRole and uses org_job_role_id. No rename is needed. What we are adding:
- When a
Gig::Jobis created, the system copiestitle,description, andrequirementsfromOrg::JobRoleonto theGig::Jobrow. This is a snapshot. It works the same way asListings::Jobsnapshotscompany_nameandoutlet_name.
The snapshot keeps the marketplace listing fast (no extra join). The foreign key keeps the validation tight (PayRates filter by role, not by string match).
How we will seed roles from legacy data
The legacy job_templates table mixes role identity, rate variants, and per-shift overrides. The seeding plan has three stages:
Stage 1: Extract
For each company in legacy, list all job_templates rows. For each row, suggest a role title by removing known suffixes like:
- Weekend- Night Shift- Overnight Shift(PH)(PH rate)- Weekday AM- Weekday PM
Output a CSV with columns: company_id, original_job_title, suggested_role_title, derived_day_type, derived_time_type, hourly_rate.
Stage 2: Admin review
An admin opens the CSV in a spreadsheet tool and fixes obvious mistakes. For example, NTUC has templates "Retail Assistant Dry", "Retail Assistant Fresh", "Retail Assistant Frozen", "Retail Assistant Non-food". These are four separate roles, not rate variants. The admin marks them as separate rows.
The CSV is the source of truth that goes into the importer.
Stage 3: Import
For each row in the reviewed CSV:
- Find or create an
Org::JobRolerow for(company, title, employment_type=:gig). - Create a
Gig::PayRaterow linked to thatOrg::JobRolewith the resolved(day_type, time_type, hourly_rate). - Make sure each
(company, role)combination has at least one rate withis_default = true.
After import, run a verification report. For each legacy template, the report checks that creating a hypothetical shift on the same conditions resolves to the same hourly rate. Mismatches are flagged for manual fix.
When this decision should be revisited
Revisit this decision only if all of these become true:
- PayRate company-wide defaults are no longer used. Every company sets every rate per outlet, with no shared defaults.
- Most customers are single-outlet businesses. The strategic accounts move away from multi-outlet operations.
- There is no plan to add HQ-level role management features in the next 12 months.
Today, none of these are true. The decision stands.
What changes in the docs
| Doc | Change |
|---|---|
33-org/models/org-job-role.md | New file. Run /model-spec org job-role to scaffold it. |
35-gig/models/gig-pay-rate.md | The existing spec already uses Org::JobRole and org_job_role_id — no rename needed. Add a section explaining how company-wide defaults eliminate per-outlet duplication. Confirm the Org::JobRole model context now points to a real model spec (rather than the schema gap noted today). |
35-gig/models/gig-job.md | The existing spec uses Org::JobRole — no rename needed. Add a section about the snapshot of title, description, and requirements from Org::JobRole at create time. |
35-gig/models/gig-pay-rate.md UC-5 | Add a business rule about shift-time boundary crossing. Use the start-time-wins rule, with a UI warning when the shift's start and end resolve to different rates. |
Open questions
- Outlet-role assignment. Should we add a join table that says "this outlet uses this role"? Today the implicit rule is "any role can be used at any outlet". A join table would help HR ask "which outlets need a Bartender?" but adds management work. Recommendation: defer until a real product need exists.
- Snapshot sync on rename. When HR renames an
Org::JobRole, do we update allGig::Jobsnapshots automatically, or keep them frozen for audit? Recommendation: update automatically by default. Add a freeze flag later if compliance asks for it. - Career::Job back-fill. New Career::Job postings reference
org_job_roles.idfrom v1 (new sign-ups go through Careers and need full-time / part-time / contract roles immediately). Legacy Career::Job rows that pre-date this change do not have the FK. Do we back-fill those rows, or leave them unlinked until product asks? Recommendation: leave unlinked in v1.
Next steps
- Run
/model-spec org job-roleto write the formal spec forOrg::JobRole. - Update
gig-pay-rate.md: link the model context'sOrg::JobRolereference to the new spec; add the company-default + outlet-override section; add the boundary-crossing rule to UC-5. - Update
gig-job.md: add the snapshot-from-Org::JobRolesection. - Add the seeding plan as a use case under
35-gig/use-cases/index.md. - Build the
Org::JobRolemodel, the Employer Dashboard "Job Roles" page, and the seeding script in that order.