diff --git a/.github/workflows/hr-workspace-employment-absence-state.yml b/.github/workflows/hr-workspace-employment-absence-state.yml
new file mode 100644
index 000000000..5de8e2d92
--- /dev/null
+++ b/.github/workflows/hr-workspace-employment-absence-state.yml
@@ -0,0 +1,55 @@
+name: HR Workspace Employment Absence State Quality
+
+on:
+ pull_request:
+ branches:
+ - develop
+ - feat/hr-workspace-protected-read-state
+ paths:
+ - "apps/hr-workspace/employment-absence-state.js"
+ - "apps/hr-workspace/employment-absence-state.css"
+ - "apps/hr-workspace/employment-absence-state.stories.js"
+ - "tests/hr-workspace-employment-absence-state.test.mjs"
+ - "docs/traceability/hr-workspace-employment-absence-state.md"
+ - "docs/doctoring/hr-workspace-employment-absence-accessibility-references.md"
+ - ".github/workflows/hr-workspace-employment-absence-state.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: hr-workspace-employment-absence-state-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ employment-absence-state:
+ name: Employment absence 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 Employment absence 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-employment-absence-state.test.mjs
+ - name: Require clean checkout
+ run: |
+ git diff --exit-code
+ test -z "$(git status --porcelain)"
diff --git a/apps/hr-workspace/employment-absence-state.css b/apps/hr-workspace/employment-absence-state.css
new file mode 100644
index 000000000..3549dde80
--- /dev/null
+++ b/apps/hr-workspace/employment-absence-state.css
@@ -0,0 +1,58 @@
+.employment-absence-state {
+ display: grid;
+ gap: var(--orgmetra-space-md);
+ max-width: 40rem;
+ 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);
+}
+
+.employment-absence-status,
+.employment-absence-next-action {
+ display: grid;
+ gap: var(--orgmetra-space-xs);
+ margin: 0;
+}
+
+.employment-absence-status span,
+.employment-absence-next-action span {
+ color: var(--orgmetra-text-muted);
+}
+
+.employment-absence-state[data-interaction-state="read-only"] {
+ border-color: var(--orgmetra-border-subtle);
+}
+
+.employment-absence-state[data-interaction-state="permission-denied"],
+.employment-absence-state[data-interaction-state="validation-error"],
+.employment-absence-state[data-interaction-state="error"] {
+ border-color: var(--orgmetra-danger);
+}
+
+.employment-absence-state[aria-busy="true"] {
+ cursor: progress;
+}
+
+.employment-absence-submit {
+ 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;
+}
+
+.employment-absence-submit:disabled {
+ cursor: not-allowed;
+ opacity: 0.62;
+}
+
+.employment-absence-submit:focus-visible {
+ outline: 3px solid var(--orgmetra-focus-ring);
+ outline-offset: 3px;
+}
diff --git a/apps/hr-workspace/employment-absence-state.js b/apps/hr-workspace/employment-absence-state.js
new file mode 100644
index 000000000..1c333df6a
--- /dev/null
+++ b/apps/hr-workspace/employment-absence-state.js
@@ -0,0 +1,79 @@
+const STATE_MODELS = Object.freeze({
+ idle: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'polite', role: 'status', submitDisabled: false,
+ interactionState: 'default', actionLabel: 'Load absence evidence',
+ label: 'Review Employment absence evidence',
+ message: 'Load fresh reason-free Employment absence evidence for the requested business date and system-knowledge cutoff.',
+ nextAction: 'Load the current governed Employment and absence evidence for this authorized HR task.',
+ }),
+ loading: Object.freeze({
+ ariaBusy: 'true', ariaLive: 'polite', role: 'status', submitDisabled: true,
+ interactionState: 'loading', actionLabel: 'Loading absence evidence',
+ label: 'Loading current absence evidence',
+ message: 'Orgmetra is resolving fresh Employment and reason-free absence evidence. Cached or partial absence evidence is not accepted.',
+ nextAction: 'Wait for the current Employment absence evidence load to finish.',
+ }),
+ absent: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'polite', role: 'status', submitDisabled: true,
+ interactionState: 'read-only', actionLabel: 'Absence evidence loaded',
+ label: 'Employment is absent at this coordinate',
+ message: 'This is reason-free operational evidence that the Employment is absent at the reviewed business/system coordinate. It does not disclose why and does not authorize leave, scheduling, compensation, or an employment decision.',
+ nextAction: 'If a consequential action is required, continue only through the separately governed leave, work-capacity, scheduling, or Employment boundary.',
+ }),
+ notAbsent: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'polite', role: 'status', submitDisabled: true,
+ interactionState: 'read-only', actionLabel: 'Absence evidence loaded',
+ label: 'Employment is not absent at this coordinate',
+ message: 'This is reason-free operational evidence that no confirmed absence is visible at the reviewed business/system coordinate. It does not prove attendance, availability, or fitness for work.',
+ nextAction: 'Continue only with the authorized HR task and resolve any separate attendance, scheduling, leave, or fitness evidence through its governed owner boundary.',
+ }),
+ denied: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false,
+ interactionState: 'permission-denied', actionLabel: 'Review access',
+ label: 'Absence evidence access denied',
+ message: 'The current purpose or actor authority does not permit access to Employment absence evidence.',
+ nextAction: 'Check the purpose and access authority before trying to load absence evidence again.',
+ }),
+ stale: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false,
+ interactionState: 'validation-error', actionLabel: 'Reload evidence',
+ label: 'Absence evidence is stale',
+ message: 'The Employment or absence truth changed before this view could be relied on.',
+ nextAction: 'Reload authoritative Employment and absence evidence at a fresh business/system coordinate before continuing.',
+ }),
+ blocked: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false,
+ interactionState: 'validation-error', actionLabel: 'Review authoritative scope',
+ label: 'Absence evidence is blocked by authoritative scope',
+ message: 'The absence view cannot be trusted while tenant, Employment, Person binding, status, or visible-version evidence is inconsistent.',
+ nextAction: 'Resolve the tenant, Employment, Person binding, status, and visible-version evidence before loading absence truth again.',
+ }),
+ error: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false,
+ interactionState: 'error', actionLabel: 'Retry governed load',
+ label: 'Employment absence evidence unavailable',
+ message: 'The governed Employment absence service did not return usable authoritative evidence.',
+ nextAction: 'Do not infer absence from cached or partial evidence; verify the service and authorization before retrying.',
+ }),
+});
+
+function requireExactState(value) {
+ if (typeof value !== 'string') {
+ throw new TypeError('Employment absence state must be an exact built-in string');
+ }
+ const model = Object.hasOwn(STATE_MODELS, value) ? STATE_MODELS[value] : undefined;
+ if (!model) throw new TypeError(`unsupported Employment absence state: ${value}`);
+ return model;
+}
+
+/** Return immutable, reason-free accessibility semantics for one Employment absence evidence state. */
+export function employmentAbsenceViewModel(state) {
+ return requireExactState(state);
+}
+
+/** Render one static Storybook proof without accepting caller-controlled worker, absence-reason, or HR values. */
+export function employmentAbsenceStateMarkup(state) {
+ const model = requireExactState(state);
+ const disabled = model.submitDisabled ? ' disabled' : '';
+ return `\n
${model.label}${model.message}
\n
Next action${model.nextAction}
\n \n`;
+}
diff --git a/apps/hr-workspace/employment-absence-state.stories.js b/apps/hr-workspace/employment-absence-state.stories.js
new file mode 100644
index 000000000..aafcadb0b
--- /dev/null
+++ b/apps/hr-workspace/employment-absence-state.stories.js
@@ -0,0 +1,25 @@
+import { employmentAbsenceStateMarkup } from './employment-absence-state.js';
+import './employment-absence-state.css';
+
+export default {
+ title: 'HR Workspace/Employment Absence States',
+ parameters: {
+ design: {
+ type: 'figma',
+ url: 'Orgmetra Baseline — Storybook Inventory node 1:64',
+ },
+ },
+};
+
+function story(state) {
+ return () => employmentAbsenceStateMarkup(state);
+}
+
+export const Idle = story('idle');
+export const Loading = story('loading');
+export const AbsentReadOnly = story('absent');
+export const NotAbsentReadOnly = story('notAbsent');
+export const PermissionDenied = story('denied');
+export const StaleEvidence = story('stale');
+export const AuthoritativeScopeBlocked = story('blocked');
+export const Error = story('error');
diff --git a/docs/doctoring/hr-workspace-employment-absence-accessibility-references.md b/docs/doctoring/hr-workspace-employment-absence-accessibility-references.md
new file mode 100644
index 000000000..3f7458f4e
--- /dev/null
+++ b/docs/doctoring/hr-workspace-employment-absence-accessibility-references.md
@@ -0,0 +1,23 @@
+# HR Workspace Employment absence accessibility references
+
+Status: **active stacked PR research/doctoring**. These references support the interaction semantics; they are not a claim of product certification, protected-`develop` availability, or compliance attestation.
+
+## Applied decisions
+
+- Use native button semantics and expose `aria-busy`, status/live-region, and alert semantics without turning the evidence surface into a custom application widget.
+- Keep reason-free absence truth explicitly read-only. The UI may communicate that an Employment is absent or not absent at one reviewed business/system coordinate, but it must not disclose or infer medical, family, statutory, disciplinary, benefit, or free-form reasons.
+- Do not infer attendance, availability, fitness for work, leave entitlement, scheduling, compensation, or employment-decision authority from absence truth.
+- Disable duplicate submission while authoritative Employment/absence evidence is loading, and make loaded evidence read-only.
+- Treat stale truth and tenant/Employment/Person/status/version inconsistency as fail-closed states with an explicit safe next action.
+- Preserve a visible keyboard focus indicator and a minimum 44 CSS-pixel action height.
+- Keep Storybook proof data value-minimized: no Person/Employment/Assignment identifiers, worker names/contact data, absence/leave reasons, medical/family/disciplinary data, compensation/benefit/rating/assessment values, credentials/tokens, prompts, or model output.
+
+## Primary final standards
+
+World Wide Web Consortium. (2023, October 5). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/
+
+World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2* (W3C Recommendation). https://www.w3.org/TR/wai-aria-1.2/
+
+## Current-status note
+
+WCAG 2.2 and WAI-ARIA 1.2 remain completed W3C Recommendations used as the normative accessibility baseline for this slice. Editorial or draft successor work does not replace those final standards here unless a future Orgmetra decision explicitly updates the baseline. This branch claims neither WCAG/ARIA conformance certification nor broader product accessibility certification.
diff --git a/docs/traceability/hr-workspace-employment-absence-state.md b/docs/traceability/hr-workspace-employment-absence-state.md
new file mode 100644
index 000000000..8579ee6dd
--- /dev/null
+++ b/docs/traceability/hr-workspace-employment-absence-state.md
@@ -0,0 +1,35 @@
+# HR Workspace Employment absence state traceability
+
+Status: **active stacked PR**. This document records the presentation/interaction contract owned by this branch. It does not claim protected-`develop` availability, accessibility certification, leave approval, or authority to mutate Employment truth.
+
+## Ownership boundary
+
+- Parent presentation contract: #130 `feat/hr-workspace-protected-read-state@b3b30058a79174000919d566fbbb1fdad80c62bf`.
+- HR Workspace product parent: #53.
+- Reason-free bitemporal Employment absence truth: #113. This branch does not import or duplicate that unmerged HRIS-kernel implementation.
+- Durable reason-free absence persistence: #114. This branch does not write its PostgreSQL relations or inherit its focused evidence.
+- Employment leave review remains separately owned by #47. Absence truth must not be translated into leave reason, entitlement, schedule, benefit, compensation, fitness, or employment-decision authority.
+- Figma correlation: `Orgmetra Baseline`, Storybook Inventory node `1:64`, freshly read on 2026-08-28. The node requires `default / hover / focus / disabled / loading / validation-error / read-only / high-risk-confirmation` as the shared inventory. This read-only absence slice uses the applicable default/loading/validation/read-only/focus states and deliberately does not invent a consequential confirmation action.
+
+## Interaction contract
+
+- `idle`: explain what fresh reason-free Employment absence evidence will be loaded and why cached evidence is insufficient.
+- `loading`: mark the surface busy and disable duplicate requests while current business/system evidence is resolved.
+- `absent`: read-only reason-free operational truth that the Employment is absent at the reviewed coordinate; no reason disclosure or consequential authority.
+- `notAbsent`: read-only truth that no confirmed absence is visible at the reviewed coordinate; it must not be interpreted as attendance, availability, or fitness for work.
+- `denied`: fail closed on purpose/authority denial and direct the user to correct authorization.
+- `stale`: require a fresh authoritative Employment/absence read rather than relying on prior evidence.
+- `blocked`: surface authoritative tenant/Employment/Person/status/version inconsistency and require that inconsistency to be resolved first.
+- `error`: prohibit inference from cached/partial evidence and explain the safe retry prerequisite.
+
+The interaction view model is constant and value-minimized. It carries no Person/Employment/Assignment identifier, worker name/contact data, absence or leave reason, medical/family/statutory/disciplinary/benefit fact, compensation/rating/assessment value, credential/token, prompt, or model output.
+
+## Evidence and accessibility
+
+The focused workflow executes the exact candidate under Node.js 24 and requires 100% line, branch, and function coverage for this interaction contract. Storybook reuses existing Orgmetra design/focus tokens and the existing Figma inventory rather than introducing a parallel design system. WCAG 2.2 and WAI-ARIA 1.2 primary final references are recorded under `docs/doctoring/hr-workspace-employment-absence-accessibility-references.md`.
+
+## Dependency-first integration
+
+Keep this PR Draft and process #53 -> #130 first. The focused child gate is stack-local evidence only; #53/#130/#113/#114/#47 checks or reviews never transfer. After #130 actually integrates, retarget/revalidate this child against fresh `develop`, reconcile intervening HR Workspace/absence contracts, and rerun every applicable browser/accessibility/Foundation/Recovery/SAST/Security and central required workflow on one resulting exact head.
+
+Do not self-approve, use routine administrator bypass, race another lifecycle writer, infer a reason or consequential HR authority from the read-only absence state, transfer predecessor evidence, or mutate a dedicated-writer dependency.
diff --git a/tests/hr-workspace-employment-absence-state.test.mjs b/tests/hr-workspace-employment-absence-state.test.mjs
new file mode 100644
index 000000000..d76e8e21d
--- /dev/null
+++ b/tests/hr-workspace-employment-absence-state.test.mjs
@@ -0,0 +1,150 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import {
+ employmentAbsenceStateMarkup,
+ employmentAbsenceViewModel,
+} from '../apps/hr-workspace/employment-absence-state.js';
+
+const story = readFileSync(
+ new URL('../apps/hr-workspace/employment-absence-state.stories.js', import.meta.url),
+ 'utf8',
+);
+const css = readFileSync(
+ new URL('../apps/hr-workspace/employment-absence-state.css', import.meta.url),
+ 'utf8',
+);
+const workflow = readFileSync(
+ new URL('../.github/workflows/hr-workspace-employment-absence-state.yml', import.meta.url),
+ 'utf8',
+);
+
+const expectedStates = {
+ idle: ['false', 'status', false, 'default', 'Review Employment absence evidence'],
+ loading: ['true', 'status', true, 'loading', 'Loading current absence evidence'],
+ absent: ['false', 'status', true, 'read-only', 'Employment is absent at this coordinate'],
+ notAbsent: ['false', 'status', true, 'read-only', 'Employment is not absent at this coordinate'],
+ denied: ['false', 'alert', false, 'permission-denied', 'Absence evidence access denied'],
+ stale: ['false', 'alert', false, 'validation-error', 'Absence evidence is stale'],
+ blocked: ['false', 'alert', false, 'validation-error', 'Absence evidence is blocked by authoritative scope'],
+ error: ['false', 'alert', false, 'error', 'Employment absence evidence unavailable'],
+};
+
+const allowedViewModelKeys = [
+ 'actionLabel',
+ 'ariaBusy',
+ 'ariaLive',
+ 'interactionState',
+ 'label',
+ 'message',
+ 'nextAction',
+ 'role',
+ 'submitDisabled',
+];
+
+test('Employment absence states are bounded, actionable, and reason-free', () => {
+ for (const [state, [ariaBusy, role, submitDisabled, interactionState, label]] of Object.entries(expectedStates)) {
+ const model = employmentAbsenceViewModel(state);
+ assert.equal(model.ariaBusy, ariaBusy);
+ assert.equal(model.role, role);
+ assert.equal(model.submitDisabled, submitDisabled);
+ 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',
+ 'workerName',
+ 'email',
+ 'phone',
+ 'absenceReason',
+ 'leaveReason',
+ 'medicalCondition',
+ 'familyReason',
+ 'disciplinaryReason',
+ 'benefitValue',
+ 'compensationValue',
+ 'ratingValue',
+ 'assessmentScore',
+ 'credential',
+ 'token',
+ 'prompt',
+ 'modelOutput',
+ ]) {
+ assert.equal(Object.hasOwn(model, forbiddenKey), false);
+ }
+
+ const markup = employmentAbsenceStateMarkup(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 (submitDisabled) assert.match(markup, /