diff --git a/.github/workflows/hr-workspace-legal-employer-history-state.yml b/.github/workflows/hr-workspace-legal-employer-history-state.yml
new file mode 100644
index 000000000..42139d38c
--- /dev/null
+++ b/.github/workflows/hr-workspace-legal-employer-history-state.yml
@@ -0,0 +1,54 @@
+name: HR Workspace Legal Employer History State Quality
+
+on:
+ pull_request:
+ branches:
+ - feat/hr-workspace-protected-read-state
+ paths:
+ - "apps/hr-workspace/legal-employer-history-state.js"
+ - "apps/hr-workspace/legal-employer-history-state.css"
+ - "apps/hr-workspace/legal-employer-history-state.stories.js"
+ - "tests/hr-workspace-legal-employer-history-state.test.mjs"
+ - "docs/traceability/hr-workspace-legal-employer-history-state.md"
+ - "docs/doctoring/hr-workspace-legal-employer-history-accessibility-references.md"
+ - ".github/workflows/hr-workspace-legal-employer-history-state.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: hr-workspace-legal-employer-history-state-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ legal-employer-history-state:
+ name: Legal employer history state contract
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout exact candidate
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ persist-credentials: false
+ - name: Prove exact candidate checkout
+ env:
+ ORGMETRA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+ run: test "$(git rev-parse HEAD)" = "$ORGMETRA_EXPECTED_HEAD_SHA"
+ - name: Set up Node.js LTS
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ with:
+ node-version: "24"
+ check-latest: false
+ - name: Run legal-employer accessibility contract with exact coverage
+ run: >-
+ node --test --experimental-test-coverage
+ --test-coverage-lines=100
+ --test-coverage-branches=100
+ --test-coverage-functions=100
+ tests/hr-workspace-legal-employer-history-state.test.mjs
+ - name: Require clean checkout
+ run: |
+ git diff --exit-code
+ test -z "$(git status --porcelain)"
diff --git a/apps/hr-workspace/legal-employer-history-state.css b/apps/hr-workspace/legal-employer-history-state.css
new file mode 100644
index 000000000..c26a23440
--- /dev/null
+++ b/apps/hr-workspace/legal-employer-history-state.css
@@ -0,0 +1,62 @@
+.legal-employer-history-state {
+ display: grid;
+ gap: var(--orgmetra-space-md);
+ max-width: 42rem;
+ padding: var(--orgmetra-space-lg);
+ border: 1px solid var(--orgmetra-border-subtle);
+ border-radius: var(--orgmetra-radius-md);
+ background: var(--orgmetra-surface-card);
+ color: var(--orgmetra-text-primary);
+}
+
+.legal-employer-history-status,
+.legal-employer-history-next-action {
+ display: grid;
+ gap: var(--orgmetra-space-xs);
+ margin: 0;
+}
+
+.legal-employer-history-status span,
+.legal-employer-history-next-action span {
+ color: var(--orgmetra-text-muted);
+}
+
+.legal-employer-history-state[data-interaction-state="read-only"] {
+ border-color: var(--orgmetra-border-subtle);
+}
+
+.legal-employer-history-state[data-interaction-state="permission-denied"],
+.legal-employer-history-state[data-interaction-state="validation-error"],
+.legal-employer-history-state[data-interaction-state="error"] {
+ border-color: var(--orgmetra-danger);
+}
+
+.legal-employer-history-state[aria-busy="true"] {
+ cursor: progress;
+}
+
+.legal-employer-history-action {
+ justify-self: start;
+ min-height: 44px;
+ padding: var(--orgmetra-space-sm) var(--orgmetra-space-md);
+ border: 0;
+ border-radius: var(--orgmetra-radius-sm);
+ background: var(--orgmetra-action-review);
+ color: #fff;
+ font: inherit;
+ cursor: pointer;
+}
+
+.legal-employer-history-action:hover:not(:disabled) {
+ opacity: 0.88;
+}
+
+.legal-employer-history-action:disabled {
+ cursor: not-allowed;
+ opacity: 0.62;
+}
+
+.legal-employer-history-action:focus-visible {
+ outline: 3px solid var(--orgmetra-focus-ring);
+ outline-offset: 3px;
+}
diff --git a/apps/hr-workspace/legal-employer-history-state.js b/apps/hr-workspace/legal-employer-history-state.js
new file mode 100644
index 000000000..0eed76ac4
--- /dev/null
+++ b/apps/hr-workspace/legal-employer-history-state.js
@@ -0,0 +1,80 @@
+const STATE_MODELS = Object.freeze({
+ idle: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'polite', role: 'status', actionDisabled: false,
+ interactionState: 'default', actionLabel: 'Load legal employer history',
+ label: 'Review legal employer history',
+ message: 'Load fresh purpose-authorized legal-employer history for the requested Employment business-time and system-knowledge coordinate.',
+ nextAction: 'Load the current authorized legal-employer history before relying on this Employee Profile evidence.',
+ }),
+ loading: Object.freeze({
+ ariaBusy: 'true', ariaLive: 'polite', role: 'status', actionDisabled: true,
+ interactionState: 'loading', actionLabel: 'Loading legal employer history',
+ label: 'Loading legal employer history',
+ message: 'Orgmetra is resolving the authorized fields and visible legal-employer versions at the requested known-at coordinate.',
+ nextAction: 'Wait for the governed legal-employer read to finish.',
+ }),
+ ready: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'polite', role: 'status', actionDisabled: true,
+ interactionState: 'read-only', actionLabel: 'Legal employer history loaded',
+ label: 'Legal employer history ready',
+ message: 'This is read-only bitemporal legal-employer history linking Employment evidence to the visible employing legal Organization. Effective time shows when the employer relationship applied; system-recorded time shows when Orgmetra knew it. Legal-employer truth is independent of Position and Assignment. This read does not authorize Employment or Organization mutation, payroll action, or statutory action.',
+ nextAction: 'Use only the authorized visible fields; start a separately authorized change if Employment or legal Organization truth must be updated.',
+ }),
+ empty: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'polite', role: 'status', actionDisabled: false,
+ interactionState: 'read-only', actionLabel: 'Reload legal employer history',
+ label: 'No legal employer is visible here',
+ message: 'No employing legal Organization is visible at this authorized business-time and known-at coordinate. This is not evidence of no Employment or no legal-employer evidence outside this coordinate.',
+ nextAction: 'Check the authorized time coordinate and reload if another business-time or known-at view is required.',
+ }),
+ denied: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', actionDisabled: false,
+ interactionState: 'permission-denied', actionLabel: 'Review access',
+ label: 'Legal employer history access denied',
+ message: 'The current purpose or actor authority does not permit this legal-employer history read.',
+ nextAction: 'Check the HR purpose and legal-organization access authority before requesting this history again.',
+ }),
+ stale: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', actionDisabled: false,
+ interactionState: 'validation-error', actionLabel: 'Reload legal employer history',
+ label: 'Legal employer evidence is stale',
+ message: 'The requested known-at coordinate or authoritative Employment/legal Organization evidence changed before this view could be relied on.',
+ nextAction: 'Reload the purpose-authorized legal-employer history at an explicit fresh known-at coordinate.',
+ }),
+ scopeBlocked: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', actionDisabled: false,
+ interactionState: 'validation-error', actionLabel: 'Narrow requested fields',
+ label: 'Legal employer fields are not authorized',
+ message: 'One or more requested legal-employer fields fall outside the current purpose-bound organization scope.',
+ nextAction: 'Narrow the requested fields to the authorized set or obtain the required HR access before retrying.',
+ }),
+ error: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', actionDisabled: false,
+ interactionState: 'error', actionLabel: 'Retry governed read',
+ label: 'Legal employer history unavailable',
+ message: 'The governed legal-employer history read did not return usable authoritative evidence.',
+ nextAction: 'Do not infer Employment, employer, Position, Assignment, payroll, or statutory state from cached or partial data; verify the service and authorization before retrying.',
+ }),
+});
+
+function requireExactState(value) {
+ if (typeof value !== 'string') {
+ throw new TypeError('legal-employer-history state must be an exact built-in string');
+ }
+ if (!Object.hasOwn(STATE_MODELS, value)) {
+ throw new TypeError(`unsupported legal-employer-history state: ${value}`);
+ }
+ return STATE_MODELS[value];
+}
+
+/** Return immutable accessibility semantics for one purpose-bound legal-employer history interaction state. */
+export function legalEmployerHistoryViewModel(state) {
+ return requireExactState(state);
+}
+
+/** Render static Storybook evidence without accepting caller-controlled HR values or identifiers. */
+export function legalEmployerHistoryStateMarkup(state) {
+ const model = requireExactState(state);
+ const disabled = model.actionDisabled ? ' disabled' : '';
+ return `\n
${model.label}${model.message}
\n
Next action${model.nextAction}
\n \n`;
+}
diff --git a/apps/hr-workspace/legal-employer-history-state.stories.js b/apps/hr-workspace/legal-employer-history-state.stories.js
new file mode 100644
index 000000000..fbcd2b0b0
--- /dev/null
+++ b/apps/hr-workspace/legal-employer-history-state.stories.js
@@ -0,0 +1,25 @@
+import { legalEmployerHistoryStateMarkup } from './legal-employer-history-state.js';
+import './legal-employer-history-state.css';
+
+export default {
+ title: 'HR Workspace/Legal Employer History States',
+ parameters: {
+ design: {
+ type: 'figma',
+ url: 'Orgmetra Baseline — Storybook Inventory node 1:64',
+ },
+ },
+};
+
+function story(state) {
+ return () => legalEmployerHistoryStateMarkup(state);
+}
+
+export const Idle = story('idle');
+export const Loading = story('loading');
+export const ReadyReadOnly = story('ready');
+export const Empty = story('empty');
+export const PermissionDenied = story('denied');
+export const StaleEvidence = story('stale');
+export const ScopeBlocked = story('scopeBlocked');
+export const Error = story('error');
diff --git a/docs/doctoring/hr-workspace-legal-employer-history-accessibility-references.md b/docs/doctoring/hr-workspace-legal-employer-history-accessibility-references.md
new file mode 100644
index 000000000..637bba593
--- /dev/null
+++ b/docs/doctoring/hr-workspace-legal-employer-history-accessibility-references.md
@@ -0,0 +1,23 @@
+# HR Workspace legal-employer history accessibility references
+
+Reviewed: 2026-08-29 (Asia/Seoul)
+
+This note records design and accessibility inputs for the active legal-employer-history interaction slice. It is engineering evidence, not a claim of accessibility certification, employment-law compliance, payroll compliance, or statutory compliance.
+
+## Primary standards
+
+World Wide Web Consortium. (2024, December 12). *Web Content Accessibility Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/
+
+World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/
+
+World Wide Web Consortium. (2025, October 21). *Web Content Accessibility Guidelines (WCAG) 2.2 approved as ISO/IEC international standard*. https://www.w3.org/press-releases/2025/wcag22-iso-pas/
+
+## Applied consequences
+
+- Loading is perceivable through `aria-busy=true`, and transient status updates use a polite live region.
+- Permission, stale-evidence, organization-scope, and transport failures use assertive alert semantics and provide a concrete next action.
+- Keyboard focus remains visible through the existing Orgmetra focus token; actionable controls preserve a 44px minimum target height.
+- Loaded legal-employer history remains explicitly read-only. UI evidence does not grant Employment or Organization mutation authority, payroll authority, statutory authority, or consequential employment-decision authority.
+- The legal-employer relationship is described independently of Position and Assignment so presentation does not collapse distinct HRIS entities.
+- Empty and stale states prohibit inference beyond the exact authorized business-time, system-knowledge, and organization-scope coordinate.
+- Figma `Orgmetra Baseline` Storybook Inventory node `1:64` was freshly re-read on 2026-08-29 and continues to enumerate Timeline, EmptyState, ErrorState, PermissionDenied, and the default, hover, focus, disabled, loading, validation-error, read-only, and high-risk-confirmation interaction vocabulary. This slice reuses that design system rather than introducing parallel geometry or tokens.
diff --git a/docs/traceability/hr-workspace-legal-employer-history-state.md b/docs/traceability/hr-workspace-legal-employer-history-state.md
new file mode 100644
index 000000000..325907b69
--- /dev/null
+++ b/docs/traceability/hr-workspace-legal-employer-history-state.md
@@ -0,0 +1,44 @@
+# HR Workspace legal-employer history interaction traceability
+
+Status: **active PR only; not protected-main truth**.
+
+## Buyer need
+
+Employee Profile needs to explain which employing legal Organization is visible for an Employment at a selected business-time and system-knowledge coordinate without conflating legal-employer truth with Job, Position, Assignment, payroll, or statutory action. The read must remain useful to authorized HR users while minimizing generic UI-state evidence.
+
+## Ownership boundary
+
+- PR #130 owns the shared protected-read accessibility semantics and Figma/Storybook interaction system.
+- PR #141 separately owns the active-PR authoritative bitemporal Employment → employing legal Organization relationship. Its backend evidence does not transfer into this UI lane and is not protected-main truth until integrated.
+- This child owns **presentation/interaction only** for legal-employer history. It introduces no Employment or Organization writer, no cross-service application-table SQL, and no dedicated-writer dependency mutation.
+
+## Governed interaction evidence
+
+`apps/hr-workspace/legal-employer-history-state.js` exposes only constant, value-minimized state semantics for `idle`, `loading`, `ready`, `empty`, `denied`, `stale`, `scopeBlocked`, and `error`.
+
+The `ready` state explains the bitemporal distinction: effective time is when the legal-employer relationship applied; system-recorded time is when Orgmetra knew it. It also states that employing legal Organization truth is independent of Position and Assignment. The state is read-only and cannot authorize Employment/Organization mutation, payroll action, or statutory action. `empty`, `stale`, and failure states prohibit inference outside the exact authorized business-time, known-at, and organization-scope coordinate.
+
+The view model intentionally carries no Person, Employment, Organization, Position, Assignment, or candidate identifiers; organization/legal names; tax/jurisdiction/payroll values; worker contact data; compensation, rating, or assessment values; credentials/tokens; prompts; or model output. Actual authorized HR values remain governed backend response data and must be handled through purpose-bound field authorization rather than embedded in generic interaction-state evidence.
+
+## Design and accessibility evidence
+
+Figma `Orgmetra Baseline` Storybook Inventory node `1:64` was freshly re-read on 2026-08-29 (Asia/Seoul). It continues to enumerate `Timeline`, `EmptyState`, `ErrorState`, `PermissionDenied`, and the required default/hover/focus/disabled/loading/validation-error/read-only/high-risk-confirmation vocabulary. This slice uses the existing Orgmetra design tokens, visible `:focus-visible` treatment, loading semantics, read-only presentation, failure alerts, and 44px action target sizing. WCAG 2.2 and WAI-ARIA 1.2 references are recorded under `docs/doctoring/hr-workspace-legal-employer-history-accessibility-references.md`.
+
+## Executable acceptance
+
+`tests/hr-workspace-legal-employer-history-state.test.mjs` requires:
+
+- the full bounded state set and concrete next actions;
+- read-only Employment/legal-Organization bitemporal explanation without mutation, payroll, or statutory authority;
+- explicit independence from Position and Assignment;
+- no unsafe inference from empty/stale/scope-blocked/error states;
+- no generic-state PII, HR identifiers, legal/tax/payroll values, credentials, prompts, or model output;
+- exact built-in string state names and rejection of prototype-inherited names such as `constructor`, `toString`, and `__proto__`;
+- Figma node correlation, Storybook inventory, existing focus/design tokens, loading/read-only/failure CSS states, and 44px action target; and
+- exact 100% owned line, branch, and function coverage in the dedicated workflow.
+
+## Contract-first repair evidence
+
+Contract head `b954b6330f5542b8c1272d4e2317c5ebf108e990` intentionally lacked the production state module. Hosted run `33219185795`, job `99009424985`, checked out and proved that exact SHA, configured Node 24.19.0, then failed at the focused contract with `ERR_MODULE_NOT_FOUND` for `apps/hr-workspace/legal-employer-history-state.js`. This is the realistic RED boundary for the buyer-visible slice; the subsequent implementation must obtain new exact-current-head GREEN evidence before the PR can advance.
+
+After #53 and #130 integrate dependency-first, this child must be retargeted to fresh `develop`, reconciled with then-current Employee Profile and the integrated/then-current legal-employer backend truth, and rerun through applicable browser/accessibility/Foundation/Recovery/SAST/Security and central required workflows. No parent or predecessor check/review transfers.
diff --git a/tests/hr-workspace-legal-employer-history-state.test.mjs b/tests/hr-workspace-legal-employer-history-state.test.mjs
new file mode 100644
index 000000000..c755c7faa
--- /dev/null
+++ b/tests/hr-workspace-legal-employer-history-state.test.mjs
@@ -0,0 +1,118 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import {
+ legalEmployerHistoryStateMarkup,
+ legalEmployerHistoryViewModel,
+} from '../apps/hr-workspace/legal-employer-history-state.js';
+
+const story = readFileSync(
+ new URL('../apps/hr-workspace/legal-employer-history-state.stories.js', import.meta.url),
+ 'utf8',
+);
+const css = readFileSync(
+ new URL('../apps/hr-workspace/legal-employer-history-state.css', import.meta.url),
+ 'utf8',
+);
+
+const expectedStates = {
+ idle: ['false', 'status', false, 'default', 'Review legal employer history'],
+ loading: ['true', 'status', true, 'loading', 'Loading legal employer history'],
+ ready: ['false', 'status', true, 'read-only', 'Legal employer history ready'],
+ empty: ['false', 'status', false, 'read-only', 'No legal employer is visible here'],
+ denied: ['false', 'alert', false, 'permission-denied', 'Legal employer history access denied'],
+ stale: ['false', 'alert', false, 'validation-error', 'Legal employer evidence is stale'],
+ scopeBlocked: ['false', 'alert', false, 'validation-error', 'Legal employer fields are not authorized'],
+ error: ['false', 'alert', false, 'error', 'Legal employer history unavailable'],
+};
+
+const allowedViewModelKeys = [
+ 'actionDisabled',
+ 'actionLabel',
+ 'ariaBusy',
+ 'ariaLive',
+ 'interactionState',
+ 'label',
+ 'message',
+ 'nextAction',
+ 'role',
+];
+
+test('legal-employer history states are bounded, actionable, and privacy-minimized', () => {
+ for (const [state, [ariaBusy, role, actionDisabled, interactionState, label]] of Object.entries(expectedStates)) {
+ const model = legalEmployerHistoryViewModel(state);
+ assert.equal(model.ariaBusy, ariaBusy);
+ assert.equal(model.role, role);
+ assert.equal(model.actionDisabled, actionDisabled);
+ assert.equal(model.interactionState, interactionState);
+ assert.equal(model.label, label);
+ assert.equal(model.ariaLive, role === 'alert' ? 'assertive' : 'polite');
+ assert.match(model.nextAction, /\.$/);
+ assert.deepEqual(Object.keys(model).sort(), allowedViewModelKeys);
+
+ for (const forbiddenKey of [
+ 'personRecordId', 'employmentRecordId', 'organizationRecordId', 'organizationUnitId',
+ 'organizationName', 'legalName', 'taxIdentifier', 'jurisdictionCode', 'payrollAccount',
+ 'positionRecordId', 'assignmentRecordId', 'workerName', 'email', 'phone',
+ 'compensationValue', 'ratingValue', 'assessmentScore', 'candidateRecordId',
+ 'credential', 'token', 'prompt', 'modelOutput',
+ ]) {
+ assert.equal(Object.hasOwn(model, forbiddenKey), false);
+ }
+
+ const markup = legalEmployerHistoryStateMarkup(state);
+ assert.match(markup, /data-figma-node-id="1:64"/);
+ assert.match(markup, new RegExp(`data-interaction-state="${interactionState}"`));
+ assert.match(markup, new RegExp(`aria-busy="${ariaBusy}"`));
+ assert.match(markup, /Next action/);
+ if (actionDisabled) assert.match(markup, /