diff --git a/.github/workflows/hr-workspace-assignment-history-state.yml b/.github/workflows/hr-workspace-assignment-history-state.yml
new file mode 100644
index 000000000..af376c572
--- /dev/null
+++ b/.github/workflows/hr-workspace-assignment-history-state.yml
@@ -0,0 +1,55 @@
+name: HR Workspace Assignment History State Quality
+
+on:
+ pull_request:
+ branches:
+ - develop
+ - feat/hr-workspace-protected-read-state
+ paths:
+ - "apps/hr-workspace/assignment-history-state.js"
+ - "apps/hr-workspace/assignment-history-state.css"
+ - "apps/hr-workspace/assignment-history-state.stories.js"
+ - "tests/hr-workspace-assignment-history-state.test.mjs"
+ - "docs/traceability/hr-workspace-assignment-history-state.md"
+ - "docs/doctoring/hr-workspace-assignment-history-accessibility-references.md"
+ - ".github/workflows/hr-workspace-assignment-history-state.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: hr-workspace-assignment-history-state-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ assignment-history-state:
+ name: Assignment 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 assignment-history 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-assignment-history-state.test.mjs
+ - name: Require clean checkout
+ run: |
+ git diff --exit-code
+ test -z "$(git status --porcelain)"
diff --git a/apps/hr-workspace/assignment-history-state.css b/apps/hr-workspace/assignment-history-state.css
new file mode 100644
index 000000000..d352c9b60
--- /dev/null
+++ b/apps/hr-workspace/assignment-history-state.css
@@ -0,0 +1,58 @@
+.assignment-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);
+}
+
+.assignment-history-status,
+.assignment-history-next-action {
+ display: grid;
+ gap: var(--orgmetra-space-xs);
+ margin: 0;
+}
+
+.assignment-history-status span,
+.assignment-history-next-action span {
+ color: var(--orgmetra-text-muted);
+}
+
+.assignment-history-state[data-interaction-state="read-only"] {
+ border-color: var(--orgmetra-border-subtle);
+}
+
+.assignment-history-state[data-interaction-state="permission-denied"],
+.assignment-history-state[data-interaction-state="validation-error"],
+.assignment-history-state[data-interaction-state="error"] {
+ border-color: var(--orgmetra-danger);
+}
+
+.assignment-history-state[aria-busy="true"] {
+ cursor: progress;
+}
+
+.assignment-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;
+}
+
+.assignment-history-action:disabled {
+ cursor: not-allowed;
+ opacity: 0.62;
+}
+
+.assignment-history-action:focus-visible {
+ outline: 3px solid var(--orgmetra-focus-ring);
+ outline-offset: 3px;
+}
diff --git a/apps/hr-workspace/assignment-history-state.js b/apps/hr-workspace/assignment-history-state.js
new file mode 100644
index 000000000..46eb1369e
--- /dev/null
+++ b/apps/hr-workspace/assignment-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 assignment history',
+ label: 'Review assignment history',
+ message: 'Load fresh purpose-authorized Assignment history for the requested business-time and system-knowledge coordinate.',
+ nextAction: 'Load the current authorized Assignment history before relying on this Employee Profile evidence.',
+ }),
+ loading: Object.freeze({
+ ariaBusy: 'true', ariaLive: 'polite', role: 'status', actionDisabled: true,
+ interactionState: 'loading', actionLabel: 'Loading assignment history',
+ label: 'Loading assignment history',
+ message: 'Orgmetra is resolving the authorized fields and visible Assignment versions at the requested known-at coordinate.',
+ nextAction: 'Wait for the governed Assignment-history read to finish.',
+ }),
+ ready: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'polite', role: 'status', actionDisabled: true,
+ interactionState: 'read-only', actionLabel: 'Assignment history loaded',
+ label: 'Assignment history ready',
+ message: 'This is read-only bitemporal Assignment history. Effective time shows when a fact applied; system-recorded time shows when Orgmetra knew it. This evidence does not authorize Assignment mutation and does not infer current worker status beyond the exact known-at snapshot.',
+ nextAction: 'Use only the authorized visible fields; start a separately authorized change if Assignment truth must be updated.',
+ }),
+ empty: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'polite', role: 'status', actionDisabled: false,
+ interactionState: 'read-only', actionLabel: 'Reload assignment history',
+ label: 'No assignment history is visible here',
+ message: 'No Assignment version is visible at this authorized business-time and known-at coordinate. This is not evidence of no Employment or no Assignment 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: 'Assignment history access denied',
+ message: 'The current purpose or actor authority does not permit this Assignment-history read.',
+ nextAction: 'Check the HR purpose and access authority before requesting Assignment history again.',
+ }),
+ stale: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', actionDisabled: false,
+ interactionState: 'validation-error', actionLabel: 'Reload assignment history',
+ label: 'Assignment history evidence is stale',
+ message: 'The requested known-at coordinate or authoritative Assignment evidence changed before this view could be relied on.',
+ nextAction: 'Reload the purpose-authorized Assignment 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: 'Assignment history fields are not authorized',
+ message: 'One or more requested Assignment-history fields fall outside the current purpose-bound authorization.',
+ 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: 'Assignment history unavailable',
+ message: 'The governed Assignment-history read did not return usable authoritative evidence.',
+ nextAction: 'Do not infer Assignment or Employment status from cached or partial data; verify the service and authorization before retrying.',
+ }),
+});
+
+function requireExactState(value) {
+ if (typeof value !== 'string') {
+ throw new TypeError('assignment-history state must be an exact built-in string');
+ }
+ if (!Object.hasOwn(STATE_MODELS, value)) {
+ throw new TypeError(`unsupported assignment-history state: ${value}`);
+ }
+ return STATE_MODELS[value];
+}
+
+/** Return immutable accessibility semantics for one purpose-bound Assignment-history interaction state. */
+export function assignmentHistoryViewModel(state) {
+ return requireExactState(state);
+}
+
+/** Render static Storybook evidence without accepting caller-controlled HR values or identifiers. */
+export function assignmentHistoryStateMarkup(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/assignment-history-state.stories.js b/apps/hr-workspace/assignment-history-state.stories.js
new file mode 100644
index 000000000..6995bf61c
--- /dev/null
+++ b/apps/hr-workspace/assignment-history-state.stories.js
@@ -0,0 +1,25 @@
+import { assignmentHistoryStateMarkup } from './assignment-history-state.js';
+import './assignment-history-state.css';
+
+export default {
+ title: 'HR Workspace/Assignment History States',
+ parameters: {
+ design: {
+ type: 'figma',
+ url: 'Orgmetra Baseline — Storybook Inventory node 1:64',
+ },
+ },
+};
+
+function story(state) {
+ return () => assignmentHistoryStateMarkup(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-assignment-history-accessibility-references.md b/docs/doctoring/hr-workspace-assignment-history-accessibility-references.md
new file mode 100644
index 000000000..9a6142aa6
--- /dev/null
+++ b/docs/doctoring/hr-workspace-assignment-history-accessibility-references.md
@@ -0,0 +1,22 @@
+# HR Workspace Assignment History accessibility references
+
+Reviewed: 2026-08-28 (Asia/Seoul)
+
+This note records design inputs for the active Assignment-history interaction slice. It is evidence for engineering decisions, not a claim of accessibility certification or legal 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, field-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 Assignment history remains explicitly read-only. UI evidence does not grant Assignment mutation authority or consequential employment-decision authority.
+- Empty and stale states prohibit inference beyond the exact authorized business-time and system-knowledge coordinate.
+- Figma `Orgmetra Baseline` Storybook Inventory node `1:64` was freshly re-read on 2026-08-28 and continues to require default, hover, focus, disabled, loading, validation-error, read-only, and high-risk-confirmation interaction states. This slice reuses that design system rather than introducing parallel geometry or tokens.
diff --git a/docs/traceability/hr-workspace-assignment-history-state.md b/docs/traceability/hr-workspace-assignment-history-state.md
new file mode 100644
index 000000000..515661211
--- /dev/null
+++ b/docs/traceability/hr-workspace-assignment-history-state.md
@@ -0,0 +1,38 @@
+# HR Workspace Assignment History interaction traceability
+
+Status: **active PR only; not protected-main truth**.
+
+## Buyer need
+
+The PRD identifies Employee Profile with bitemporal Assignment history as a P1 HRIS surface. A customer needs to understand what Assignment evidence was effective and what Orgmetra knew at a selected system-knowledge coordinate without converting that read into mutation authority or leaking fields outside the authorized HR purpose.
+
+## Ownership boundary
+
+- PR #142 owns the separate purpose-bound People API Assignment-history read contract. Its active-PR backend evidence does not transfer into this UI lane.
+- PR #130 owns the shared protected-read accessibility semantics and Figma/Storybook interaction system.
+- This child owns **presentation/interaction only** for Assignment history. It introduces no Assignment writer, no cross-service SQL, and no dedicated-writer dependency mutation.
+
+## Governed interaction evidence
+
+`apps/hr-workspace/assignment-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 Assignment fact applied; system-recorded time is when Orgmetra knew it. The state is read-only and cannot authorize Assignment mutation. `empty`, `stale`, and failure states prohibit inference outside the exact authorized business-time and known-at coordinate.
+
+The view model intentionally carries no Person, Employment, Assignment, Job, Position, or Organization identifiers; worker names/contact data; compensation, rating, assessment, or candidate values; credentials/tokens; prompts; or model output. Actual authorized HR values remain backend response data and must be handled through purpose-bound field authorization rather than embedded in generic UI-state evidence.
+
+## Design and accessibility evidence
+
+Figma `Orgmetra Baseline` Storybook Inventory node `1:64` was freshly re-read on 2026-08-28. The executable Storybook stories correlate to that node and reuse existing Orgmetra CSS tokens, visible `:focus-visible` treatment, loading semantics, read-only presentation, failure alerts, and 44px action target sizing. Current W3C WCAG 2.2 and WAI-ARIA 1.2 references are recorded under `docs/doctoring/hr-workspace-assignment-history-accessibility-references.md`.
+
+## Executable acceptance
+
+`tests/hr-workspace-assignment-history-state.test.mjs` requires:
+
+- the full bounded state set and concrete next actions;
+- read-only bitemporal explanation without mutation authority;
+- no unsafe inference from empty/stale/scope-blocked/error states;
+- 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.
+
+After #53 and #130 integrate dependency-first, this child must be retargeted to fresh `develop`, reconciled with then-current Employee Profile and #142 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-assignment-history-state.test.mjs b/tests/hr-workspace-assignment-history-state.test.mjs
new file mode 100644
index 000000000..8eac87365
--- /dev/null
+++ b/tests/hr-workspace-assignment-history-state.test.mjs
@@ -0,0 +1,121 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import {
+ assignmentHistoryStateMarkup,
+ assignmentHistoryViewModel,
+} from '../apps/hr-workspace/assignment-history-state.js';
+
+const story = readFileSync(
+ new URL('../apps/hr-workspace/assignment-history-state.stories.js', import.meta.url),
+ 'utf8',
+);
+const css = readFileSync(
+ new URL('../apps/hr-workspace/assignment-history-state.css', import.meta.url),
+ 'utf8',
+);
+const workflow = readFileSync(
+ new URL('../.github/workflows/hr-workspace-assignment-history-state.yml', import.meta.url),
+ 'utf8',
+);
+
+const expectedStates = {
+ idle: ['false', 'status', false, 'default', 'Review assignment history'],
+ loading: ['true', 'status', true, 'loading', 'Loading assignment history'],
+ ready: ['false', 'status', true, 'read-only', 'Assignment history ready'],
+ empty: ['false', 'status', false, 'read-only', 'No assignment history is visible here'],
+ denied: ['false', 'alert', false, 'permission-denied', 'Assignment history access denied'],
+ stale: ['false', 'alert', false, 'validation-error', 'Assignment history evidence is stale'],
+ scopeBlocked: ['false', 'alert', false, 'validation-error', 'Assignment history fields are not authorized'],
+ error: ['false', 'alert', false, 'error', 'Assignment history unavailable'],
+};
+
+const allowedViewModelKeys = [
+ 'actionDisabled',
+ 'actionLabel',
+ 'ariaBusy',
+ 'ariaLive',
+ 'interactionState',
+ 'label',
+ 'message',
+ 'nextAction',
+ 'role',
+];
+
+test('assignment-history states are bounded, actionable, and privacy-minimized', () => {
+ for (const [state, [ariaBusy, role, actionDisabled, interactionState, label]] of Object.entries(expectedStates)) {
+ const model = assignmentHistoryViewModel(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', 'assignmentRecordId', 'jobRecordId',
+ 'positionRecordId', 'organizationRecordId', 'workerName', 'email', 'phone',
+ 'compensationValue', 'ratingValue', 'assessmentScore', 'candidateRecordId',
+ 'credential', 'token', 'prompt', 'modelOutput',
+ ]) {
+ assert.equal(Object.hasOwn(model, forbiddenKey), false);
+ }
+
+ const markup = assignmentHistoryStateMarkup(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, /