Identity Isn't Your HR System
The finding read: nine days of unauthorized access by a terminated employee.
It was wrong, and it was also unarguable, and the argument about which of those was more true consumed most of a quarter.
Here is what happened. A contractor-turned-employee in a regional sales office resigned on 3 March. Her last day was 3 March. Her manager, who had eleven other things happening that week, entered the termination into Workday on 12 March with an effective date of 3 March — which is the correct thing to do, and which HR's own process explicitly permits, because payroll for that period hadn't run yet. The nightly SCIM job saw the change on the morning of the 13th and deprovisioned the account within four minutes of picking it up. By the identity platform's own standards it behaved perfectly: it received an event and acted on it faster than its SLA required.
Between the 3rd and the 13th, the account logged in nine times. All nine were her. She was finishing a handover she had agreed to do unpaid, from her personal laptop, out of professional decency.
The audit finding was generated three months later by a control that joins two tables: the HR extract's termination_date and the identity platform's authentication log. Nine authentication events after termination. The control was correct. The control had also been running for two years and had never fired, because until that quarter nobody had joined those two tables.
Then someone noticed the worse thing. HR had corrected a record. The manager's original entry had said 6 March; HR fixed it to 3 March a week later, because the resignation letter said the 3rd. In Workday's history the termination has always been the 3rd — effective-dated records are like that. So the size of the exposure window in the audit report was not a property of anything the identity platform did or failed to do. It was a property of an edit made in a different system, months afterward, by someone who had no idea an access-control metric depended on it.
That is the shape of the problem. Not "HR data is dirty" — everyone knows HR data is dirty. The problem is that HR systems and identity systems have incompatible relationships with time, and every integration between them that ignores this produces exactly one of two artifacts: an access gap nobody can close, or an audit trail that changes after the fact.
The line, stated plainly
The HRIS is authoritative for employment facts. The identity platform is authoritative for credentials and access. These are different categories, and almost nobody disputes the categories in the abstract — the fights are entirely about the middle.
| Genuinely HR's | Genuinely identity's | Contested, and where the pain lives |
|---|---|---|
| Employment status and its full enum | Login identifier (sub, UPN) |
Email address |
| Hire date, last working day, effective dates | Credential material, hashes, keys | Display name |
| Job code, grade, FLSA class | Enrolled authentication factors | Group membership |
| Cost centre, legal entity, payroll country | Sessions and their lifetime | Org hierarchy / manager |
| Manager of record | Entitlement grants and their provenance | Preferred / chosen name |
| Legal name, tax identifiers | Lifecycle state of the account | Contractor end date |
| Position, reporting line, org unit | Consent and authorization grants | Location / work address |
The right-hand column isn't ambiguous because those facts are philosophically hard. It's ambiguous because each of them has one owner for its value and a different owner for its consequences, and integrations are usually written by someone who only holds one of the two.
The value test — does an access decision depend on it, does identity have authority to change it, does it move slower than a session — is worked in detail in Keep Business Logic Out of Identity, and I'm not going to re-derive it. The organizational version — who is accountable for the answer, and why four departments each have a legitimate claim — is Why Enterprise Identity Projects Fail Before Anyone Writes Code. This article is about the third axis, the one that isn't in either: time.
HR is bitemporal. Identity is an actuator.
An HRIS is a bitemporal database, whether or not its vendor uses that word. Every fact carries two independent time dimensions:
- Valid time (Workday calls it the effective date): when the fact is true in the world.
- Transaction time (the record date, entered-on,
lastModified): when the system learned about it.
These come apart constantly, in both directions, and that is a feature rather than a defect. A promotion is entered on the 20th effective the 1st of next month — transaction time before valid time. A termination is entered on the 12th effective the 3rd — transaction time after valid time. A correction rewrites the valid-time record entirely: the fact becomes something it was never previously recorded as being, and the old version survives only in an audit sub-table almost no integration reads.
HR is also, by design, eventually correct. There is a whole apparatus — retro pay, adjustment runs, reversals, prior-period corrections — devoted to the proposition that facts entered wrong will be fixed later and the fixes will propagate to money. That apparatus works. It is one of the better-engineered parts of enterprise software.
The identity platform is not a database in this sense at all. It is an actuator. It takes actions in the world that have consequences at the moment they occur, and those consequences do not have valid time. A session that existed, existed. A token that was minted, was used. A file that was downloaded is on someone's laptop. There is no adjustment run for a credential.
An HRIS is bitemporal and eventually correct; an identity platform is unitemporal and immediately consequential. A correction in a bitemporal system is a cheap UPDATE. The same correction, projected into an actuator, is not a correction — it is a retroactive statement about an exposure that already happened, and the actuator has no way to act on it.Once you have that sentence, every classic HR-to-identity failure stops looking like a set of unrelated bugs and starts looking like one mechanism observed from different angles. Here are five of the angles.
1. The late termination, and what your audit trail must actually say
The entry lag on terminations is not a data-quality problem you can improve your way out of, because of a structural fact that almost never gets said out loud:
HR's correctness deadline is the payroll cut-off. Identity's is minutes. Nobody at HR is behind schedule.
If payroll runs semi-monthly, a termination entered any time before the next cut-off is on time by every standard the HRIS is measured against. The manager who entered it on the 12th did not miss an SLA; there was no SLA to miss, because the system's actual deadline was the 15th. Every organization I've looked at closely has a median termination entry lag of one to three days and a p95 somewhere between eight and fifteen — and involuntary terminations are usually fast (HR runs those) while voluntary ones and contractor end-dates are the long tail (a line manager runs those, between other work).
So the deprovisioning gap is real, it is measured in days, and the interesting engineering question is not how to eliminate it. It's what your records say about it.
The wrong design — the one that produced the audit finding above — stores a single termination_date on the identity record, sourced from HR, and lets the sync overwrite it. That field is a projection of HR's valid time, and when HR corrects it, your access history silently changes shape. You cannot reconstruct what you knew and when.
The right design stores both clocks and never mutates either:
lifecycle_event {
person_id: "wd-88213"
event_type: TERMINATION
effective_at: 2026-03-03T23:59:59Z # HR valid time
reported_at: 2026-03-12T14:02:11Z # HR transaction time
observed_at: 2026-03-13T02:14:40Z # when identity received it
acted_at: 2026-03-13T02:18:07Z # when identity actually disabled
source: "workday:prod"
source_version: 4 # this is the 4th version of this fact
supersedes: "evt-9f21c"
}
Four timestamps, and each answers a different question that someone will eventually ask you.
acted_at − observed_atis your SLA. This is the only number the identity team is accountable for. Four minutes. Defend it.observed_at − reported_atis the integration's latency — polling interval, batch window, queue depth. Owned by whoever runs the sync.reported_at − effective_atis the organizational latency, and it is not yours. Nine days. Report it, trend it, escalate it, but do not let it be scored against the identity platform, because no amount of identity engineering moves it.acted_at − effective_atis the exposure window, which is the number the auditor actually wants and which no single team controls.
Decomposing that one number into three owned by three different groups is the single highest-value thing you can do to this integration, and it costs three extra columns. Without it, every conversation about deprovisioning is a conversation about whose fault it is, conducted with one number that contains everybody's contribution.
And the correction case now has a defined behaviour: version 4 of the fact arrives, you write a new event that supersedes the old one, and you do not rewrite history. Your exposure report for March says what you believed in March, with a note that the underlying fact was later restated. An audit trail that changes after the fact is not an audit trail; it's a record you can no longer testify to.
2. The future-dated transfer, applied immediately
This one is more embarrassing because it is entirely self-inflicted.
Most HRIS export APIs, asked for a worker, return the record as of a date — and the default asOfDate is today, but a great many integrations are built against a "changed since" feed that surfaces the transaction, not the effective state. So on 20 March you receive a record change for a transfer effective 1 April. The naive sync does the obvious thing: it maps department, writes it, and recomputes group membership.
The person moves from Support to Finance eleven days early. On 20 March they lose access to the ticketing queue they are still working, and gain access to the financial reporting they will not need until April. Both halves are wrong, and only one of them generates a helpdesk ticket, which is how these live for years.
The inverse is just as common and less visible: an integration that filters on effective date <= today never applies the transfer at all, because the record's transaction date is in the past and it will never change again. On 1 April nothing happens. The person keeps their old access and quietly accumulates the new access when someone raises a ticket. This is how movers become the worst-privileged population in the company: leavers get deprovisioned eventually, joiners get provisioned promptly, and movers only ever gain.
The mechanism you need is not complicated, but it has to be deliberate: a future-dated event is accepted, persisted, and scheduled — not applied. Identity keeps a queue of pending transitions keyed on effective_at, and a clock that fires them. Which means identity now owns a scheduler, and the scheduler needs the properties schedulers need: it must be idempotent, it must survive being down over a boundary and catch up, it must handle a scheduled event being cancelled (transfers get called off more often than anyone expects — I'd put it at a few percent), and it must handle two pending transitions for the same person that arrive out of order.
This is real work. It is also the price of consuming an effective-dated feed: skip it and you haven't saved the work, you've just chosen which failure mode you get.
3. The rehire that inherits a previous life
Six months after leaving, she comes back. Workday, correctly, reuses the same worker ID: it's the same person, with a continuous employment history, and pension and tenure calculations depend on that continuity.
The sync sees an active worker with person_id = wd-88213 and a disabled account with the same ID. The most natural implementation in the world re-enables the account.
Here is what she gets back, in order of how bad it is:
| Inherited | Should it survive a rehire? |
|---|---|
The sub / account identifier |
Arguably yes — continuity for audit joins |
| Enrolled MFA factors | Usually yes, if the device is still hers and re-verified |
| Group memberships from the previous role | No. She came back into a different job |
| Direct entitlement grants | No, and these are the ones that never get reviewed |
| Standing OAuth authorization grants to third-party apps | No, and nobody will ever notice these |
| Long-lived refresh tokens, PATs, API keys she created | Absolutely not, and these are often still valid |
| Recovery contacts (the personal phone she used) | Depends, and the answer requires a policy nobody wrote |
The usual fix is to re-run an access review after a rehire — a control that depends on someone noticing the rehire. The structural fix is to model what HR is actually telling you about: not a person, but an employment stint. HR knows about stints; it computes service dates from them. Most identity models flatten a stint sequence into a boolean.
If your identity record carries person_id and stint_id, then rehire is unambiguous: a new stint means a new grant scope, entitlements are keyed to the stint that granted them, and everything from stint 1 expires with stint 1 automatically rather than by review. You keep the stable subject identifier for continuity, and you lose the access. That's the correct pair of outcomes, and it falls out of the data model instead of out of a process.
4. Everyone who isn't in HR at all
This is the objection that kills the entire "HR is our source of truth for people" framing, and it is usually raised too late to change the architecture.
Contingent workers — agency contractors, consultants, MSP staff, seasonal labour, offshore delivery teams — are frequently 20–40% of the population with system access, and in some industries considerably more. They are typically not in the HRIS, because the HRIS is licensed per employee and its purpose is paying people you employ. They live in a vendor management system, or a procurement tool, or a spreadsheet owned by a delivery manager, or nowhere.
They're not the only gap:
- The pre-boarding window. A new hire needs a laptop, an email address, and an account before day one, because that's how onboarding works now. On the day you must create the account, there is frequently no active HR record at all — some HRIS configurations don't expose a worker until the hire is finalized, and future-dated hires are the same scheduling problem as §2 with an even sharper edge.
- Executives and restricted records. Senior staff, employees in an active HR investigation, and people going through certain accommodations often have their records restricted in HR — the integration user simply cannot see them, or sees a redacted version. Your feed is silently missing exactly the population with the most access.
- M&A. After an acquisition you have two HRISes for eighteen months; neither is authoritative for everyone, and both are authoritative for someone.
- Non-human identity. Service accounts, workloads, and agents were never in HR and never will be, and they are now the majority of identities in most estates.
Which produces the conclusion that actually matters architecturally:
Identity's population is a strict superset of HR's, permanently. Therefore HR cannot be the root of identity's lifecycle state machine — only one of several event sources feeding it.
If you build the state machine as "HR record exists → account exists," you will spend the next three years bolting on parallel mechanisms for each excluded population, each with its own semantics, none of them reviewed. If you build it as "identity owns a lifecycle; several sources propose transitions," contractors are a source with a different event schema and a mandatory end date, and the pre-boarding window is just a PRE_ACTIVE state entered by a recruiting event. The second design absorbs the fifth population you didn't know about; the first one doesn't.
5. The name change
The legal-name change looks like a trivial attribute sync and is the case where a purely mechanical integration does the most human damage.
HR is genuinely authoritative for legal name — it's on the tax filing. But legal name and display name are different fields with different consumers, and only one of them appears in a Slack sidebar. A person may change their chosen name months before the legal change completes, or may never make a legal change at all, and in most jurisdictions the legal update requires documents that take weeks.
Three requirements fall out, and they're all identity's:
- Display name must be changeable in identity, ahead of HR, without a legal record. If your only write path for display name is the HR sync, you have made a person's ability to be addressed correctly at work dependent on a government office's processing queue.
- The login identifier must never be derived from a name. If usernames are
firstname.lastname, a name change is an identifier change, which is an account migration, which touches every downstream system that keyed on it — exactly the failure that keying on email instead ofsubproduces, in a more personal register. - The audit trail has a genuine conflict here, and it needs an explicit answer. Immutable logs are immutable; the old name is in a million records. The workable design is to log the stable subject identifier rather than names, and resolve names at render time through the current record. If you have been writing display names into audit events, a name change is the moment you discover you built a system that cannot forget, coupled to a system that must.
Point 3 generalizes: log identifiers, resolve names at read time. It costs a join, it solves a class of problems that includes GDPR rectification, and almost nobody does it because writing the name into the event is one line shorter.
The anti-pattern: "just read the manager field from HR"
This request arrives in a specific form. We need approval routing / access requests / a break-glass approver, and the manager is right there in Workday. Can identity look it up when the decision is made?
Three independent reasons this is wrong, and they get stronger in that order.
Availability. An HRIS is sized and operated for a population that logs in during business hours, in one or two time zones, and it has scheduled maintenance windows measured in hours — announced, regular, and entirely reasonable for a system where nobody needs to run payroll at 03:00 on a Saturday. Your authorization path has no such window. Composing a system with a 99.5%-shaped operational posture into a decision path that needs four nines is the same arithmetic worked in Identity Is Not Your Integration Layer, and it comes out the same way: the availability of the composite is the product, and you have just made your access decisions inherit HR's change calendar.
Latency and shape. HRIS APIs are built for batch integration, not point queries — bulk-oriented, often SOAP or a report-as-a-service endpoint, commonly with per-tenant concurrency limits in the low single digits and responses in the hundreds of milliseconds to seconds, and frequently metered. Putting one on an authorization path doesn't just add latency; it adds a dependency that rate-limits you precisely when volume spikes.
Correctness, which is the real reason. The manager field is wrong more often than anyone in the room believes, and the errors are systematic:
- Vacant positions. When a manager leaves, their reports frequently roll up to a position placeholder or to the departed manager's own former manager, and the field points at something that cannot approve anything.
- Manager of record ≠ the person you actually work for. Matrix organizations, dotted lines, secondments, project-based staffing. HR's field holds the one with budget authority, which is often not the one who knows whether you should have access to a system.
- Reorg lag. During a reorganization, entire subtrees are inaccurate for weeks by design, because HR is staging the change to take effect on a date.
- Self-reporting cycles. In every large directory I have looked at there are people whose manager chain loops, and people who manage themselves.
In the estates I've had numbers for, 3–8% of manager references were unusable at any given moment — pointing at a terminated worker, a placeholder, or a cycle. That is a fine error rate for an org chart and a catastrophic one for an approval control, because the failure is silent: the request routes to a mailbox nobody reads, it isn't denied, it gets stuck, and stuck approvals are resolved by someone with admin rights clicking approve.
The correct version is unglamorous: consume the reporting relationship as an event, store it in identity (or better, in the access-request system) with a sourced_at and a staleness budget, and make the unresolvable case an explicit, alerting outcome — route to a named fallback approver, and count how often that happens. That count is a data-quality metric you can hand back to HR with evidence, which is the only form in which HR data quality ever actually improves.
The shape that works
Identity keeps its own lifecycle state machine. HR events are typed, versioned, effective-dated inputs that propose transitions. Identity applies its own policy and owns the outcome.
flowchart LR
subgraph sources["Event sources (none authoritative alone)"]
HR["HRIS<br/>employees"]
VMS["Vendor mgmt<br/>contractors"]
ATS["Recruiting<br/>pre-boarding"]
SEC["Security<br/>break-glass"]
end
HR --> N
VMS --> N
ATS --> N
SEC --> N
N["Normalizer<br/>typed event + 4 timestamps<br/>+ version + supersedes"] --> S{"effective_at<br/>in future?"}
S -->|yes| Q["Scheduled transitions<br/>(cancellable, idempotent)"]
S -->|no| P["Policy: may this transition apply?"]
Q -->|clock fires| P
P --> M["Identity lifecycle state<br/>PRE_ACTIVE · ACTIVE · SUSPENDED<br/>DISABLED · DEPROVISIONED"]
M --> E["Downstream provisioning + audit"]
P -.->|"past-dated"| G["Exposure gap record"]
Four properties make this different from a sync, and each one is a decision you have to make deliberately:
Identity's states are not HR's enum. HR's employment status is rich — active, on leave, garden leave, notice period, secondment, contractor-via-agency, rehire pending — and every value is a domain concept that will be redefined without telling you. Identity wants a small set of states it defines itself, and the mapping from HR's enum to identity's states is business logic that lives in the normalizer, versioned, tested, with a defined behaviour for an unrecognized value. (Fail closed on an unknown status, and alert. HR adds values to that enum without a release note.)
Transitions are asymmetric, and this is the load-bearing rule. Security can suspend ahead of HR, unilaterally, and no HR event may un-suspend. HR can propose a termination; it cannot propose a reinstatement that overrides an active security hold. If your sync is a symmetric state mirror, then the moment HR's record flips back for any reason — a correction, a reversal, a bad extract — your incident containment silently evaporates at 02:00. I have seen this exact thing: an account suspended during an investigation, re-enabled by the nightly job because the HR record still said active, with the audit trail showing two legitimate changes by two legitimate principals.
Missing input is not a permissive default. A feed that stops delivering must not read as "no terminations today." It reads as unknown, and after a threshold it escalates. Silence and health are indistinguishable in an event stream unless you make the stream heartbeat.
No HR field is ever a live authorization input. Not at token issuance, not at policy evaluation, not at the gateway. HR facts enter identity as events, become state, and are evaluated as state. The moment an authorization decision reaches back into the HRIS, you have coupled your access path to a system whose operational posture was designed around a monthly cycle.
Fact, owner, consumers, and what happens when they disagree
The artifact worth producing is not a system diagram. It's this table, per fact, with the fourth column filled in — because the fourth column is the one that turns an agreement into code. The authority map in the program-failure piece covers who may write each element; this one covers what the runtime does at 03:00 when two systems disagree, which is a different question and the one that actually ships.
| Fact | Authoritative source | Who consumes it | Resolution when they disagree |
|---|---|---|---|
| Employment status (HR enum) | HRIS | Normalizer only | HRIS wins. Nothing else may read the raw enum |
| Account lifecycle state | Identity | Everything | Identity wins, always. HR proposes; identity decides |
| Security suspension | Identity / SecOps | Everything | Suspension wins over any HR-sourced active state, unconditionally |
| Last working day | HRIS | Scheduler, exposure reporting | HRIS wins, as a new versioned event; prior versions retained, never overwritten |
| Contractor end date | Vendor management | Scheduler | VMS wins; absence is treated as expired, not as no-end-date |
| Login identifier | Identity | Everything downstream | Identity wins. HR has no opinion and must not be allowed to form one |
| Primary email | Contested — pick one, write it down | Identity, HR, collaboration suite | Whoever provisions the mailbox. If that's the collaboration suite, HR's field is display-only and read-only in both others |
| Display / chosen name | Identity (self-service) | Collaboration, apps | Identity wins over HR's legal name. Legal name is a separate field with separate consumers |
| Legal name | HRIS | Payroll, tax, background checks | HRIS wins. Must not be the login identifier or the display name |
| Manager (approval routing) | HRIS, replicated | Access request system | HRIS wins on value; unresolvable resolves to a named fallback and alerts, never to no-approver |
| Cost centre | HRIS | Finance, chargeback | HRIS wins. Not an access input — if something gates on it, that's a finding |
| Group membership | Identity + entitlement workflow | Applications | Identity wins. HR-derived groups are one input among several, and must be marked as such |
| MFA enrolment | Identity | Identity | Identity wins. HR has no visibility and should have none |
Two rows deserve emphasis. The contractor row — absence is treated as expired — is the single most valuable default in the table, because contingent worker records are maintained worse than employee records and an unbounded contractor account is the most common way a leaver keeps access for a year. And the email row exists because "primary email" is the fact most likely to have three writers in a mature enterprise, none of whom knows about the other two.
Where merging is genuinely fine
I want to be honest about this, because the boundary has a real cost and a real threshold.
If you have 60 employees, one country, no contractors with system access, one HR product, and the person who enters terminations is the same person who can disable an account, then merging is correct. Your HRIS's provisioning module or your Google Workspace directory is your identity lifecycle, and building an event-driven state machine to serve one termination a month is the kind of engineering that gets a team fired. The temporal problems all still exist; they're just resolved by a human who holds both jobs and notices.
The threshold isn't headcount, though headcount correlates. It's these, and one is enough:
- A contingent workforce with system access above roughly 10%. You now have a population HR structurally cannot see, and the merged design has no place to put it.
- The person who enters HR data is not the person who can fix an access problem. This is the real one. The moment those separate, the entry lag becomes an access risk owned by someone who cannot see it and isn't measured on it.
- A contractual or regulatory deprovisioning SLA measured in hours. Payroll-cycle-paced data entry cannot satisfy an hours-based SLA. You need a second event source — usually the manager, via a lightweight leaver form that hits identity directly and HR separately.
- More than one HR system. M&A, a separate international payroll, a PEO. There is no longer a single source to merge with.
- More than one country. Works councils, restricted records, and data-residency constraints on HR data mean your feed will be partial by law, and a merged design has no concept of a person it isn't allowed to see.
Below that line, don't build this. Above it, you will build it eventually, and the cost of building it late is that you'll be migrating live entitlements rather than designing them.
The tells
You can diagnose a merged design without reading any code:
- Deprovisioning is measured with one number, and its owner is the identity team.
- The exposure window in your audit report changes when you re-run last quarter's report.
- Somebody's access changed on a day when nothing was deployed and nothing was requested — it was a scheduled effective date, or a nightly job.
- A support ticket says access was fixed, and it broke again overnight. (Two writers, one value — the mechanism is in Keep Business Logic Out of Identity.)
- There is a spreadsheet of contractors, and it is load-bearing.
- Rehires are handled by a documented manual checklist.
- Your identity platform's admin UI shows an editable
departmentfield that the sync overwrites. - Nobody can tell you what happens if the HR feed delivers zero records tomorrow.
That last one is the fastest diagnostic in the list. Ask it in a design review and watch the room.
What to do on Monday
- Instrument the four timestamps. Even if you change nothing else, split the exposure window into organizational latency, integration latency, and your SLA. You cannot argue about the right number until you have three.
- Measure
reported_at − effective_atfor the last twelve months of terminations, split by voluntary and involuntary. The distribution will surprise your security team and it will not surprise HR, and that gap in expectations is the whole conversation. - Find out what your sync does with a future effective date. Send one through in a test tenant. There are only two possible answers and both are bugs unless someone chose one.
- Test a rehire. Create, terminate, re-enable, then enumerate what came back. Pay attention to refresh tokens and standing OAuth grants; those are the ones nobody checks.
- Ask what fraction of your access population is not in the HRIS. If the answer is a guess, that's the finding.
- Kill the feed for a day in staging and confirm your platform reports unknown rather than nothing changed.
- Write the fourth column of the table above for the ten facts you actually integrate. Not who owns them — what happens when they disagree.
The summary is short. HR is authoritative for employment; identity is authoritative for access; and the thing that connects them is not a field mapping but a translation between two different models of time. HR gets to be eventually correct because payroll can be adjusted. Identity does not get that, because a session cannot be un-issued — and any design that treats an HR record as a live description of who has access has quietly assumed otherwise.
Disclosure: I work on ClavionX. The design constraint that forced most of this thinking is architectural rather than a feature: the runtime never synchronously calls the control plane during request execution — state arrives by events and projections, and a missing projection fails secure rather than reaching back for a fresh read. That rules out "look it up in HR at decision time" as an implementation option, which means the lifecycle state, the effective-dating, and the behaviour on a silent feed all have to be worked out explicitly up front. It's a real constraint with real costs. The reasoning here holds whether you run Entra, Okta, Keycloak, or something you wrote yourself.