Skip to main content

Identities domain decisions

This page records the ratified design decisions for the Identities domain. Read it before you design anything in this domain.

Each decision states what we decided, why, and where the evidence is. Decisions live only on this page. Other docs must link here instead of repeating the text. When a decision changes, update this page the same day.

Schema truth is jodapp-api/docs/db/identities.dbml. Verify columns there before you rely on this page.

D1 — What Identities::User means (2026-07-24)

Identities::User answers two questions only:

  1. Can this account log in? (authentication)
  2. Which single human is this? (one account per person)

Everything else belongs to a role object:

  • Worker data lives on Talent::Profile.
  • Employer data lives on Org::Membership — a person acting for one Org::Company.

The rule for engineers: identity holds what makes an account unique and lets a human in (email, mobile, password, government ID). Profiles and memberships hold what a role does with that human. A column that only one role reads does not belong on the identity table.

D2 — Move address_geo_area_id, gender, date_of_birth to talent_profiles (2026-07-24)

These three columns move from identities_users to talent_profiles. The new columns are nullable.

Why (full code trace of both repos, 2026-07-24):

  • No business rule reads them. There is no job matching by the worker's area, no age check, and no gender rule anywhere in the codebase. The only reads are display.
  • Only talent flows consume them. The employer portal never reads the employer person's own address, gender, or birth date.
  • gender, date_of_birth (and gov_identity_number, identity_verified) are written only by the JodGig sync. Native signup and profile setup never collect them.
  • The NOT NULL on address_geo_area_id forced the employer migration to invent a value (the company's area, with a precedence rule for super-HQ users). Moving the column deletes that workaround. See the employer sync spec.
  • Signup currently requires "Address Area" from every user, including employers, with helper text about job recommendations that nothing implements. Removing it cuts signup friction (see D4).

Timezone impact is near zero. The worksite-authoritative timezone rule says the official clock always comes from the work location (outlet, careers job) — never from the worker's own area. One serializer violates this rule today (Careers::CandidatesJobApplicationListSerializer resolves time from the candidate's own area). It must switch to the job's area, and that fix is owed with or without this move.

Ordering: this move lands before the employer sync writes any rows, so the sync never implements the geo-area workaround.

Gender note: on identities_users, unknown gender is stored as male because of the column default. On talent_profiles the column is nullable, so unknown stays unknown.

D3 — gov_identity_number and identity_verified stay on identities_users (2026-07-24)

These two columns do not move. They are identity concerns.

Why: their purpose is one government ID = one human = one account. Legacy JodGig suffered from users creating repeat accounts with generated NRICs. The duplicate-account guard must run at signup, before any profile exists. A guard on talent_profiles would fire too late.

SingPass constraints that shaped this decision:

  • SingPass verification will be required for talent signups. It is a one-time fetch of verified identity data, not the login method. SingPass charges per login, so normal login stays email or OTP.
  • Jakarta has no SingPass. Verification must be modeled as a provider-agnostic event, not as a SingPass feature.

Future shape (build only when the SingPass work starts, not before): an identities_user_verifications table — provider, verified_at, and a reference to the audit payload. identity_verified stays as the cached flag. A Jakarta provider becomes a new row type, not a schema redesign. SingPass data lands in two homes: the ID number and verified flag on the identity; verified demographics (birth date, sex, address) on the talent profile.

Known gap: gov_identity_number has no index at all, so the duplicate guard has no database enforcement yet. It needs a unique partial index (WHERE gov_identity_number IS NOT NULL). Audit the legacy data for duplicates first — the generator-fake NRICs will collide.

D4 — Signup stays minimal (direction, 2026-07-24)

Target talent signup: first name, last name, mobile, and email with OTP. After that the user can apply for a job immediately. Address and all other profile data are collected later — during profile setup, first application, or SingPass verification.

After D2, identities_users holds exactly this minimal set plus auth fields. The domain boundary and the signup UX converge on the same table shape.

This supports the signup friction work in the information architecture effort.

Open blocker if signup becomes passwordless-first: password_digest is NOT NULL. This is a separate decision.

Findings from the 2026-07-24 audit (not decisions, do not lose)

  • NRIC leak (urgent): Identities::UserBaseSerializer exposes gov_identity_number, date_of_birth, gender, and identity_verified. EmployersUserSerializer inherits all of it, so the employer applicant-detail endpoint serves the worker's NRIC. No screen displays it. One-line serializer fix; PDPA risk.
  • Profile seeding wart (parked): Identities::Users::CreateManager seeds a talent_profile for every signup, including employer persons. To be redesigned as its own decision — it interacts with D4 (applying with a thin or absent profile must work).