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:
- Can this account log in? (authentication)
- 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 oneOrg::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(andgov_identity_number,identity_verified) are written only by the JodGig sync. Native signup and profile setup never collect them.- The
NOT NULLonaddress_geo_area_idforced 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 official display timezone rule says a time displays on the clock of the entity that owns the moment — gig times use the outlet's clock, careers times use the company's clock (decided 2026-07-29/30, extended to candidate screens 2026-08-02). The worker's own area is never an official clock under any of these rules. One serializer still reads the candidate's own area today (Careers::CandidatesJobApplicationListSerializer) — it moves to the company clock under jodapp-api#1902, with or without this column 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.
D5 — Server-side sessions replace jwt_sessions (2026-09-06)
jwt_sessions is the Ruby gem that signs the JWT access token inside today's session cookie and replaces that token every hour. A server-side session is one database row. The browser holds a signed cookie with the row's id. A mobile app holds a token whose digest is on the row. Rails reads the row on every request, and nothing refreshes.
Ali decided this on 2026-09-06. It is a Rails decision with one effect on Board Web: the web app never renews a credential. Board Web D1 — Nothing refreshes; a 401 means the session is gone records that side.
What we decided
Ali rules on each part in turn. This table holds only the parts he has ruled on. The parts still open are listed under it, in the order we take them.
| Part | Decision | Ruled |
|---|---|---|
| Session record | One table per identity system: identities_user_sessions for Identities::User and identities_admin_sessions for Identities::Admin. One row per sign-in | 2026-09-06 |
| Refresh | None. Nothing rotates. A 401 means the session row is gone. The Rails Security Guide's session model has no refresh: the cookie holds only the row id, and the row expires in the database | 2026-09-06 |
| Endpoints | The user system has four: POST /identities/sessions (login), GET /identities/sessions/current (read the current session), DELETE /identities/sessions/current (logout), DELETE /identities/sessions (sign out everywhere). The Team system has the same four under /team/identities/admins/sessions. PATCH .../current, the refresh, is removed | 2026-09-06 |
| Which credential a login issues | The login body says which: credential: :cookie is the default and credential: :token asks for a bearer token. A request from a browser origin is refused a token | 2026-09-06 |
| Columns | Both tables have the same columns. Only the owner column differs. The full list is under this table | 2026-09-06 |
| Which value the cookie holds | The signed id, as the Rails generator does. An API response never shows id, only uuid | 2026-09-06 |
| CSRF | csrf_token lives on the row. Login writes it into the readable cookie CSRF_TOKEN, or TEAM_CSRF_TOKEN for Team. Every cookie-authenticated write sends it back in X-CSRF-Token, and Rails compares the two in constant time. A failure answers 403 with csrf_token_missing or csrf_token_invalid, never 401. A bearer request needs none. Rails' own protect_from_forgery is not used: its origin check requires the page and the API to share one origin, and jodapp.com and api.jodapp.com do not | 2026-09-06 |
token_digest | The column and its unique index ship with the table. The token login and the bearer lookup ship with the mobile app's first issue | 2026-09-06 |
| Last use | updated_at is the last-used column, touched at most once per touch interval. There is no last_active_at | 2026-09-06 |
No expires_at | The idle and absolute limits live in configuration, and the cleanup job computes expiry in its query | 2026-09-06 |
| Lifetimes | Users: idle limit 7 days, absolute limit 30 days. Admins: idle limit 1 day, absolute limit 7 days. The idle limit reads updated_at, the absolute limit reads created_at. A person who uses Jod at least weekly stays signed in and signs in again at most once a month. An admin signs in again after a full day away and at least once a week. Both limits live in configuration, per identity system, so either can change without a migration | 2026-09-06 |
| Touch interval | 15 minutes. A request touches updated_at only when the row's updated_at is older than 15 minutes | 2026-09-06 |
Cookie expires | The absolute limit, set at login. The cookie cannot outlive the longest possible row | 2026-09-06 |
| The session cookie | jodapp_session_id on the user domain, teamjod_session_id on the Team domain. httponly: true; secure: true outside development; same_site: :lax; the domain from config.x.session_cookie_domains, never from request.host. The names say which system set the cookie, and a generic name could collide with another app's cookie on .jodapp.com | 2026-09-06 |
| The CSRF cookie | CSRF_TOKEN and TEAM_CSRF_TOKEN, unchanged. Readable by page scripts. Same domain, same_site, secure and expires as the session cookie | 2026-09-06 |
What GET .../sessions/current returns | Today's JSON, unchanged: identities_user, talent_access, employer_access. Nothing about the row itself. What each key may hold is ruled in Identities D6 — The current-session answer holds the person and one access word per area | 2026-09-06, narrowed by D6 on 2026-09-24 |
| What a successful login returns | The same JSON as GET .../sessions/current. A login with credential: :token adds token at the top level, shown once | 2026-09-06 |
| What a failed login returns | 422 with code invalid_login and one generic message, as today. A rate-limited login answers 429 with code rate_limited; the limit is 10 attempts in 3 minutes from one IP address, the Rails guide's own example | 2026-09-06 |
| Who enforces the limits | The read path. When Rails finds the row by cookie or by token, it also checks updated_at against the idle limit and created_at against the absolute limit, through one scope on the model, not_expired. An expired row answers 401 before any job deletes it. The cleanup job only keeps the table small, so a stopped job is never a security problem | 2026-09-06 |
| The cleanup job | Two jobs, one per table: Identities::DeleteExpiredUserSessionsJob and Identities::DeleteExpiredAdminSessionsJob. Hourly at minute 20 on the low queue, retry: 0. Each deletes the rows the not_expired scope excludes, in batches of 1000. How we know they ran is the row "How we know the cleanup jobs ran" below. No log line is used as a control | 2026-09-06 |
| Cutover | None. The Rails sessions and the jodapp-web changes ship together, and every signed-in person signs in once more. There is no dual-read, no second credential at login, and no upgrade of a JWT session on read. On the day, jodapp-api deploys first and jodapp-web right after, in one sitting, because the new web client looks only for the new cookie. The old jwt_access cookies and the gem's Redis keys expire on their own within seven days. Corrected 2026-09-07: the two identity systems cut over in two sittings, admins first. The admin PR on jodapp-api and the web's admin cookie-name change deploy together, QA first, then production. Users follow the same way when their PRs are ready. The two systems share no code, so the Team Portal is the rehearsal | 2026-09-06, corrected 2026-09-07 |
| Where the row lives during a request | CurrentRequest.identities_user_session beside CurrentRequest.identities_user, and CurrentRequest.identities_admin_session beside CurrentRequest.identities_admin. Logout destroys it; a password change keeps it and destroys the owner's other rows | 2026-09-06 |
| Where the limits live | config.x.sessions in config/application.rb, with keys identities_user, identities_admin (each idle_limit and absolute_limit) and touch_interval | 2026-09-06 |
| Configuration key names | config.x.session_cookie_domains changes its keys from user and team to identities_user and identities_admin, so it reads the same as config.x.sessions | 2026-09-06 |
| A request with both a cookie and a bearer header | Rails checks the cookie first, then the header. No client sends both | 2026-09-06 |
| Model names | Identities::UserSession and Identities::AdminSession, as Rails derives them from the table names | 2026-09-06 |
| A password reset ends every session | The reset that starts at POST /identities/users/password/forgot and ends at PATCH /identities/users/password destroys every row of that user, because the person is not signed in and the password may have leaked. PATCH /team/identities/admins/password, which sets an admin's password with an invite or reset token, does the same for an admin. No signed-in password change endpoint exists. When one is built, it keeps the current row and destroys the rest | 2026-09-06, corrected 2026-09-07 |
| Bearer-token sessions and cookie sessions share the limits | The idle and absolute limits belong to the row, not to how the row is found. A longer limit for the mobile app, if ever wanted, is one configuration value | 2026-09-06 |
The 401 code | Both identity systems answer 401 with code unauthorized and no other code. admin_identity_required goes. Talent::BaseController answers 403 talent_profile_required to a signed-in user with no profile, because 401 means signed out and nothing else | 2026-09-07 |
credential: :token before the mobile app exists | Every login that asks for credential: :token answers 422 credential_not_allowed. The mobile app's first issue adds the token path and narrows the refusal to browser origins | 2026-09-07 |
| Where the login rate limit counts | rate_limit counts in the Rails cache store, one key per IP address, expiring after 3 minutes. Production and QA use redis_cache_store, so the count is shared across every Puma worker and instance. No table | 2026-09-07 |
Secure on the cookies | On everywhere except development and test. Tests run over plain HTTP | 2026-09-07 |
| The cleanup job's retry | Each job class sets retry: 0 on itself. sidekiq-scheduler passes only the queue to an ActiveJob class, so a retry line in the yml is ignored | 2026-09-07 |
| An unverified account at login | A login by an account with neither is_email_verified nor is_phone_verified answers 422 verification_required, not invalid_login, so the client can send the person to the OTP that verifies them. A normal sign-up verifies by OTP before a password exists, so today the only unverified accounts come from the JodGig migration, which copies is_phone_verified and sets is_email_verified: false. Built in Rails PR 7 | 2026-09-09 |
| Session endpoints and classes name their table | The user session endpoints are POST /identities/user_sessions, GET and DELETE /identities/user_sessions/current, and DELETE /identities/user_sessions. The admin ones are the same under /team/identities/admin_sessions. The controllers are Identities::UserSessionsController and Team::Identities::AdminSessionsController. The domain classes live in Identities::UserSessions and Identities::AdminSessions, and the web API modules are identitiesUserSessionsApi and identitiesAdminSessionsApi. Identities::UserSessions::Current stays the composed answer to the read, beside the row Identities::UserSession. Rails keeps the old paths as aliases until the web has switched, so the rename ships in three PRs after the user cutover. The admin web module name in this row is replaced by the row dated 2026-09-12 | 2026-09-09 |
| The login body is flat, and the two session controllers read it as sent | The login body carries identifier or email and password at the top level. A user sends identifier, an admin sends email. The four endpoints prints both bodies. Both session controllers set wrap_parameters false. Without it Rails guesses one body key from the controller class name, and guesses the fields it accepts from that model's columns. The rename changed that guess, so each controller now reads the body exactly as the client sent it. The login answer is the session document and nothing else. It has no user key, because no page reads the login answer. Still open, not decided: turn the parameter wrapper off for the whole app, and write "a body is never wrapped" into the codebase rules. Ali wants proof first that no endpoint breaks | 2026-09-10 |
| The admin web API module follows the Team file-naming rule | The module is app/api/team-identities-admin-sessions-api.js and its export is teamIdentitiesAdminSessionsApi. The web names a Team module team-{domain}-{resource}-api.js after its Rails path, and that rule needs no exception to remember. The user module is app/api/identities-user-sessions-api.js and its export is identitiesUserSessionsApi. The two session getters follow their modules: getIdentitiesUserSessionsCurrent() and getTeamIdentitiesAdminSessionsCurrent() | 2026-09-12 |
| The cache store per environment | Production and QA use redis_cache_store, so the login rate limit's count is shared across every Puma worker and instance. Test and development use memory_store, so the limit counts there too: the suite can prove the 429, and an engineer sees it on a laptop. rate_limit keeps its default store; no controller names one | 2026-09-14 |
| The API sits behind Cloudflare, and Rails trusts Cloudflare's addresses as proxies | Every request reaches Rails through Cloudflare, then HAProxy, then kamal-proxy. Rails takes request.remote_ip from the nearest forwarded address it does not trust, and its default trusted list holds only private ranges, so it recorded Cloudflare's edge address, which differs per request. The login rate limit, the session row's ip_address and the Sentry enricher all read remote_ip, so the limit never counted to ten and the other two stored the wrong address. One initializer lists Cloudflare's published IPv4 and IPv6 ranges beside Rails' own defaults, as IPAddr values, pinned as a literal list with the source URL and the date copied. The list is never fetched at boot, because a boot that depends on Cloudflare being reachable is a boot that can fail | 2026-09-15 |
| How we know the cleanup jobs ran | The Sidekiq admin panel's "Recurring Jobs" tab, added by sidekiq-scheduler, shows both jobs with the last time each was enqueued and the next time it will be. A run that raises goes to the panel's "Dead" tab at once, because the jobs have no retries, and the exception reaches Sentry as an ordinary error event through the Sidekiq error handler. The jobs send no Sentry cron check-in: Sentry includes one cron monitor and charges USD 0.78 a month for each further one, and a stopped job only lets a table grow, because the lookup enforces the limits on every request. Nothing alerts when the scheduler itself stops; a free alert for that is a later decision | 2026-09-21 |
The columns of identities_user_sessions. identities_admin_sessions is identical, with identities_admin_id as the owner.
| Column | Type | Why it exists |
|---|---|---|
id | bigint, primary key | The value the signed cookie holds. One primary-key read is the whole check |
uuid | string, unique, not null | Repo convention: every table has one. It is the row's identifier in any API response, never id |
identities_user_id | bigint, foreign key, not null, indexed | The owner. Sign out everywhere finds every row of one owner |
token_digest | string, null, unique index | The SHA-256 digest of a bearer token. Null on a cookie session |
csrf_token | string, null | The token a cookie-authenticated write must send back. Null on a token session |
ip_address | string, null | Recorded at login, as the Rails generator does. For support and a later "your signed-in devices" page |
user_agent | string, null | The same |
created_at | datetime, not null | When the sign-in happened. The absolute limit reads it |
updated_at | datetime, not null | When the row was last used. The idle limit reads it |
Nothing is open. Every part above is ruled.
Why two tables and not one with an owner type: the two identity systems must never share identity data. With two tables and two concerns, a wrong-side lookup is impossible, because the user concern never reads the admin table. One table with an owner type would need a check on every read.
Why
- Refresh caused real defects. Two API clients carry one cookie: the browser and the Board Web server. After an hour both meet a
401. Both refresh. Rails accepts one and deletes the old token at once. The web app onmainhad three defects from this: a shared refresh lock that could hand one visitor another visitor's cookies, a dead cookie after a server-side refresh, and a race between two refreshes on one in-app click. No web convention can fix a race that the API creates. - We paid for JWT without its benefit. A JWT's one advantage is a check that needs no store lookup.
jwt_sessionskeeps every token in Redis and refuses any request whose token is not in the store. So today's authentication already depends on a store lookup, which is the thing a JWT was supposed to avoid. A table row does the same job with less machinery. - Mobile needs a header, not rotation. React Native's cookie support is unstable, so a mobile app needs a credential it can send in a header. A random token stored on the session row is that credential. Rotation was never required for it.
- Rails ships this shape. The Rails 8 authentication generator uses a sessions table and a signed cookie. Following it means fewer things to maintain: no Redis for auth, no refresh endpoints, no CSRF tokens from a gem.
Evidence
| Fact | Where |
|---|---|
| The gem checks Redis on every request | jwt_sessions 3.2.4, lib/jwt_sessions/authorization.rb:150: invalid_authorization unless session_exists?(token_type). The store is set in config/initializers/jwt_sessions.rb: token_store = :redis, database 1 |
| A refresh deletes the old access token before it issues the new one | lib/jwt_sessions/session.rb, refresh_by_uid: AccessToken.destroy(@_refresh.access_uid, store) runs first. store_adapters/redis_store_adapter.rb, destroy_access sends DEL |
| A second refresh with the replaced token is refused | session.rb, valid_access_request? returns false unless uid == refresh_token.access_uid. Verified on 2026-09-05 with bin/rails runner on the memory store: the second refresh gets 401 |
| Token lifetimes today | config/initializers/jwt_sessions.rb: access_exp_time = 3600, refresh_exp_time = 604800 |
| The three web defects | jodapp-web at 406701bb: app/api/ky-client.js:25 holds one refreshPromise per Node process; :78-80, :234-245, :262-266; app/auth/auth-user-session-middleware.js:43, :74. The stopgap and the removal are tracked under roadmap#72 |
| Mobile cookies are unstable | React Native, Networking: "Cookie based authentication is currently unstable". facebook/react-native#23185, open since 2019 |
| The Rails 8 shape | The Rails authentication generator's concern; ActionController::HttpAuthentication::Token. has_secure_token stores the raw token, so the digest is written by hand |
Every web host and its API share one site, so same_site: :lax is enough | Production: jodapp.com calls api.jodapp.com, and teamjod.app calls api.teamjod.app. QA: jodapp.dev and team.jodapp.dev call api.jodapp.dev. Sources: config/domains.yml, config.x.session_cookie_domains in config/environments/*.rb, and the jodapp-web Actions variables VITE_API_JODAPP_URL and VITE_API_TEAMJOD_URL. Today's cookies use same_site: :none, which is more than needed. Confirm in QA before the production deploy |
| Session cookie domains come from configuration per identity system | config/environments/production.rb: config.x.session_cookie_domains = { user: '.jodapp.com', team: '.teamjod.app' }. Shipped in jodapp-api#2034 |
D6 — The current-session answer holds the person and one access word per area (2026-09-24)
The current-session answer is the JSON of GET /identities/user_sessions/current. Login returns the same JSON. JodApp Web reads it on every page request, public pages included.
Ali decided this on 2026-09-24.
What we decided
| Part | Decision | Ruled |
|---|---|---|
| What the answer holds | Three keys, side by side, and nothing else:identities_user: id, first_name, last_name, emailtalent_access: stateemployer_access: state | 2026-09-24 |
| What may join it | Only a fact about who the signed-in person is, or the one access word of an area. No company data. No role data. No ids of other records. This holds even when a page needs the field, because the answer reaches every page | 2026-09-24 |
| Where a page gets anything else | From its own endpoint, in its own loader | 2026-09-24 |
| Which company the person acts as | Not in the answer. JodApp Web reads it from GET /identities/users/current/org_memberships: the entry with is_selected: true. Org D1 — is_selected always marks the membership Rails acts as keeps that entry right | 2026-09-24 |
| The two fields that leave | employer_access.current_org_membership_id and employer_access.org_memberships. JodApp Web stops reading current_org_membership_id first. Rails stops sending both after that | 2026-09-24 |
The state words and what each one means are in JodApp Web — The facts a rule may read.
Why
- The answer reaches every page, so every field in it goes everywhere.
- JodApp Web reads it on every page request, including public job pages.
- It replaces
GET /identities/users/currentas the answer every page reads. That endpoint loads 13 associations and sendsgov_identity_numberanddate_of_birth. - Without a written rule, each new need adds one field, and the answer grows back into what it replaced.
- The two fields had one reader, and it did not need them.
- The
loaderofapp/layouts/org-dashboard/org-dashboard-layout.jsxreadcurrent_org_membership_idto pick one entry fromGET /identities/users/current/org_memberships. - That
loaderalready calls the endpoint, for the company's name and the person's role. - Nothing read
org_memberships. The company switcher it was added for was never built.
- The
- One question should have one answer, in one response.
- "Which company does this person act as?" had two answers:
current_org_membership_idin the session, andis_selectedon the membership row. - They could point at different rows. The session showed the row
Org::Memberships::CurrentResolveServicepicked, and the column kept the person's last choice. - Org D1 makes
is_selectedalways mark the row Rails acts as. So the memberships endpoint answers the question alone, beside the name and role the page needs.
- "Which company does this person act as?" had two answers:
- No entry rule reads anything but the state words. A rule that decides entry reads
talent_access.stateandemployer_access.state, and nothing else.
Evidence
| Fact | Where |
|---|---|
| The first written scope rule, and the fields it allowed | docs 83e08b3, docs/50-59-frontend/41-board-web/auth-architecture/02-auth-architecture-the-session.md:264-280: "Do not add a field unless a policy rule or the persistent page frame needs it on every page." The folder left main in 7b7a028 (2026-09-09), and the rule left with it |
| The two fields arrived on 2026-08-26, for the company switcher | docs 6b7eb511 and db007d28; jodapp-api#1969; shipped in jodapp-api#2014 |
| The endpoint the answer replaces | jodapp-api#1969: GET /identities/users/current "eager-loads 13 association nodes" and exposes gov_identity_number and date_of_birth |
The only reader of current_org_membership_id | jodapp-web at 84d64c69, app/layouts/org-dashboard/org-dashboard-layout.jsx:32-39 |
org_memberships had no reader | jodapp-web at 84d64c69: git grep finds only spec fixtures, the JSDoc in app/api/identities-user-sessions-api.js:40, and a comment in app/auth/auth-entry-rules.js:14 |
| The switcher was never built | jodapp-web#1406 is open |
| The mobile app does not read the answer | JOD_MobileApp_V3_GLOBAL branch prod, 2026-08-05: no call to /identities/user_sessions/current |
| The two answers could disagree | jodapp-api at bc04d829b, test/controllers/identities/user_sessions_controller_test.rb:825-855: "The saved choice stays in the column. Only the answer changes" |
| Rails fails a test when any field joins | test/controllers/identities/user_sessions_controller_test.rb:549-589 compares the whole document: "A new field anywhere fails this test" |
Findings from the 2026-07-24 audit (not decisions, do not lose)
- NRIC leak (urgent):
Identities::UserBaseSerializerexposesgov_identity_number,date_of_birth,gender, andidentity_verified.EmployersUserSerializerinherits 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::CreateManagerseeds atalent_profilefor 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).