Skip to main content

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::Job is reused across many shifts.
  • A Gig::Job already carries title, description, outlet_id, and requirements. These are the same fields a role table would carry.
  • Adding Org::JobRole means 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::JobRole is master data. It describes what kind of work a company hires for. It does not depend on which outlet is open today.
  • Gig::Job is 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:

  1. A change that should be done once at HQ becomes 30 changes across 30 outlets.
  2. 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_jobs rows 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_jobs row is archived when the outlet closes.
  • When Tampines reopens, the manager creates a new gig_jobs row.
  • 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_jobs row is archived. The Casual Crew row in org_job_roles is not affected.
  • When Tampines reopens, the manager picks "Casual Crew" from a dropdown.
  • The new gig_jobs row copies its title, description, and requirements from org_job_roles automatically.
  • 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::Job is 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::Job only, the role concept dies and is recreated each time an outlet churns. With Org::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) and Listings::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 category string column to Org::JobRole with 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::JobRole row does not match the business reality.
  • HR can still get cross-employment-type insight by joining on Org::JobRole.title or 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:

ColumnTypeNotes
id, uuidbigint, stringstandard
org_company_idbigint, FKwhich company owns this role
titlestringrole name (for example, "Casual Crew"). Snapshotted to gig_jobs.title and listings_jobs.title.
descriptiontextwhat the role does. Snapshotted to gig_jobs.description and listings_jobs.description.
requirementstextoptional. Snapshotted to gig_jobs.requirements; projects to listings_jobs.requirements_raw at marketplace time.
employment_typestringAll 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.
statusstring: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_bybigint, FKorg_memberships audit
admin_created_by, admin_updated_bybigint, FKidentities_admins audit
created_at, updated_atdatetimestandard

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::JobRole model spec does not exist yet") is now closed by the new Org::JobRole model.
  • The resolution chain stays the same:
    1. Look for an exact match at outlet level: (company, outlet, role, day_type, time_type).
    2. Fall back to company level: (company, outlet=NULL, role, day_type, time_type).
    3. Fall back to the default rate for (company, role).

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::Job is created, the system copies title, description, and requirements from Org::JobRole onto the Gig::Job row. This is a snapshot. It works the same way as Listings::Job snapshots company_name and outlet_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:

  1. Find or create an Org::JobRole row for (company, title, employment_type=:gig).
  2. Create a Gig::PayRate row linked to that Org::JobRole with the resolved (day_type, time_type, hourly_rate).
  3. Make sure each (company, role) combination has at least one rate with is_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:

  1. PayRate company-wide defaults are no longer used. Every company sets every rate per outlet, with no shared defaults.
  2. Most customers are single-outlet businesses. The strategic accounts move away from multi-outlet operations.
  3. 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

DocChange
33-org/models/org-job-role.mdNew file. Run /model-spec org job-role to scaffold it.
35-gig/models/gig-pay-rate.mdThe 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.mdThe 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-5Add 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

  1. 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.
  2. Snapshot sync on rename. When HR renames an Org::JobRole, do we update all Gig::Job snapshots automatically, or keep them frozen for audit? Recommendation: update automatically by default. Add a freeze flag later if compliance asks for it.
  3. Career::Job back-fill. New Career::Job postings reference org_job_roles.id from 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

  1. Run /model-spec org job-role to write the formal spec for Org::JobRole.
  2. Update gig-pay-rate.md: link the model context's Org::JobRole reference to the new spec; add the company-default + outlet-override section; add the boundary-crossing rule to UC-5.
  3. Update gig-job.md: add the snapshot-from-Org::JobRole section.
  4. Add the seeding plan as a use case under 35-gig/use-cases/index.md.
  5. Build the Org::JobRole model, the Employer Dashboard "Job Roles" page, and the seeding script in that order.