diff --git a/.github/workflows/hr-workspace-position-lifecycle-review-state.yml b/.github/workflows/hr-workspace-position-lifecycle-review-state.yml
new file mode 100644
index 000000000..f0bfcf5a2
--- /dev/null
+++ b/.github/workflows/hr-workspace-position-lifecycle-review-state.yml
@@ -0,0 +1,55 @@
+name: HR Workspace Position Lifecycle Review State Quality
+
+on:
+ pull_request:
+ branches:
+ - develop
+ - feat/hr-workspace-protected-read-state
+ paths:
+ - "apps/hr-workspace/position-lifecycle-review-state.js"
+ - "apps/hr-workspace/position-lifecycle-review-state.css"
+ - "apps/hr-workspace/position-lifecycle-review-state.stories.js"
+ - "tests/hr-workspace-position-lifecycle-review-state.test.mjs"
+ - "docs/traceability/hr-workspace-position-lifecycle-review-state.md"
+ - "docs/doctoring/hr-workspace-position-lifecycle-review-accessibility-references.md"
+ - ".github/workflows/hr-workspace-position-lifecycle-review-state.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: hr-workspace-position-lifecycle-review-state-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ position-lifecycle-review-state:
+ name: Position lifecycle review 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 Position lifecycle review 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-position-lifecycle-review-state.test.mjs
+ - name: Require clean checkout
+ run: |
+ git diff --exit-code
+ test -z "$(git status --porcelain)"
diff --git a/apps/hr-workspace/position-lifecycle-review-state.css b/apps/hr-workspace/position-lifecycle-review-state.css
new file mode 100644
index 000000000..14ad995a7
--- /dev/null
+++ b/apps/hr-workspace/position-lifecycle-review-state.css
@@ -0,0 +1,58 @@
+.position-lifecycle-review-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);
+}
+
+.position-lifecycle-review-status,
+.position-lifecycle-review-next-action {
+ display: grid;
+ gap: var(--orgmetra-space-xs);
+ margin: 0;
+}
+
+.position-lifecycle-review-status span,
+.position-lifecycle-review-next-action span {
+ color: var(--orgmetra-text-muted);
+}
+
+.position-lifecycle-review-state[data-interaction-state="high-risk-confirmation"] {
+ border-color: var(--orgmetra-action-review);
+}
+
+.position-lifecycle-review-state[data-interaction-state="permission-denied"],
+.position-lifecycle-review-state[data-interaction-state="validation-error"],
+.position-lifecycle-review-state[data-interaction-state="error"] {
+ border-color: var(--orgmetra-danger);
+}
+
+.position-lifecycle-review-state[aria-busy="true"] {
+ cursor: progress;
+}
+
+.position-lifecycle-review-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;
+}
+
+.position-lifecycle-review-submit:disabled {
+ cursor: wait;
+ opacity: 0.62;
+}
+
+.position-lifecycle-review-submit:focus-visible {
+ outline: 3px solid var(--orgmetra-focus-ring);
+ outline-offset: 3px;
+}
diff --git a/apps/hr-workspace/position-lifecycle-review-state.js b/apps/hr-workspace/position-lifecycle-review-state.js
new file mode 100644
index 000000000..37e6529e2
--- /dev/null
+++ b/apps/hr-workspace/position-lifecycle-review-state.js
@@ -0,0 +1,86 @@
+const STATE_MODELS = Object.freeze({
+ idle: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'polite', role: 'status', submitDisabled: false,
+ interactionState: 'default', actionLabel: 'Load Position evidence',
+ label: 'Review Position lifecycle evidence',
+ message: 'Load the fresh Position and Assignment evidence before making a human lifecycle review.',
+ nextAction: 'Load the current governed Position and staffing evidence for one lifecycle review.',
+ }),
+ loading: Object.freeze({
+ ariaBusy: 'true', ariaLive: 'polite', role: 'status', submitDisabled: true,
+ interactionState: 'loading', actionLabel: 'Loading Position evidence',
+ label: 'Loading governed Position evidence',
+ message: 'Orgmetra is waiting for fresh Position and Assignment evidence. No cached lifecycle truth is used.',
+ nextAction: 'Wait for the current evidence load to finish.',
+ }),
+ review: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'polite', role: 'status', submitDisabled: false,
+ interactionState: 'high-risk-confirmation', actionLabel: 'Confirm lifecycle review',
+ label: 'Position lifecycle change requires human confirmation',
+ message: 'The evidence is read-only input for human review. Confirming this review does not apply, freeze, close, abolish, or reactivate a Position.',
+ nextAction: 'Confirm the reviewed lifecycle evidence before recording the human review.',
+ }),
+ recording: Object.freeze({
+ ariaBusy: 'true', ariaLive: 'polite', role: 'status', submitDisabled: true,
+ interactionState: 'loading', actionLabel: 'Recording human review',
+ label: 'Recording human Position lifecycle review',
+ message: 'Orgmetra is recording the human review evidence. Duplicate submission is disabled.',
+ nextAction: 'Wait for immutable review evidence to be recorded.',
+ }),
+ recorded: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'polite', role: 'status', submitDisabled: true,
+ interactionState: 'read-only', actionLabel: 'Review recorded',
+ label: 'Human Position lifecycle review recorded',
+ message: 'The recorded review is evidence only and does not apply a Position lifecycle mutation.',
+ nextAction: 'Return to the Position lifecycle queue. Authoritative application is separate and must re-resolve fresh Position and Assignment truth.',
+ }),
+ denied: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false,
+ interactionState: 'permission-denied', actionLabel: 'Review access',
+ label: 'Position lifecycle review access denied',
+ message: 'The current purpose or reviewer authority does not permit this Position lifecycle review.',
+ nextAction: 'Check the access purpose and reviewer authority before trying again.',
+ }),
+ stale: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false,
+ interactionState: 'validation-error', actionLabel: 'Reload evidence',
+ label: 'Position lifecycle evidence is stale',
+ message: 'The Position or Assignment evidence changed before review recording.',
+ nextAction: 'Reload fresh authoritative Position and Assignment evidence before reviewing again.',
+ }),
+ blocked: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false,
+ interactionState: 'validation-error', actionLabel: 'Reload staffing evidence',
+ label: 'Position lifecycle change is blocked by staffing state',
+ message: 'The current staffing evidence conflicts with the proposed lifecycle change. This screen cannot override that safety boundary.',
+ nextAction: 'Reload authoritative staffing evidence and resolve the staffing conflict before reviewing again.',
+ }),
+ error: Object.freeze({
+ ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', submitDisabled: false,
+ interactionState: 'error', actionLabel: 'Retry governed load',
+ label: 'Position lifecycle review unavailable',
+ message: 'The governed evidence or immutable review service did not return a usable result. No cached review is accepted.',
+ nextAction: 'Verify the service and authorization before retrying the governed evidence load.',
+ }),
+});
+
+function requireExactState(value) {
+ if (typeof value !== 'string') {
+ throw new TypeError('Position lifecycle review 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 Position lifecycle review state: ${value}`);
+ return model;
+}
+
+/** Return immutable, value-minimized accessibility semantics for one Position lifecycle review state. */
+export function positionLifecycleReviewViewModel(state) {
+ return requireExactState(state);
+}
+
+/** Render one static Storybook proof without accepting caller-controlled HR values. */
+export function positionLifecycleReviewStateMarkup(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/position-lifecycle-review-state.stories.js b/apps/hr-workspace/position-lifecycle-review-state.stories.js
new file mode 100644
index 000000000..ba3185f8f
--- /dev/null
+++ b/apps/hr-workspace/position-lifecycle-review-state.stories.js
@@ -0,0 +1,26 @@
+import { positionLifecycleReviewStateMarkup } from './position-lifecycle-review-state.js';
+import './position-lifecycle-review-state.css';
+
+export default {
+ title: 'HR Workspace/Position Lifecycle Review States',
+ parameters: {
+ design: {
+ type: 'figma',
+ url: 'Orgmetra Baseline — Storybook Inventory node 1:64',
+ },
+ },
+};
+
+function story(state) {
+ return () => positionLifecycleReviewStateMarkup(state);
+}
+
+export const Idle = story('idle');
+export const Loading = story('loading');
+export const HighRiskHumanReview = story('review');
+export const Recording = story('recording');
+export const RecordedReadOnly = story('recorded');
+export const PermissionDenied = story('denied');
+export const StaleEvidence = story('stale');
+export const StaffingBlocked = story('blocked');
+export const Error = story('error');
diff --git a/docs/doctoring/hr-workspace-position-lifecycle-review-accessibility-references.md b/docs/doctoring/hr-workspace-position-lifecycle-review-accessibility-references.md
new file mode 100644
index 000000000..ffbc10ca6
--- /dev/null
+++ b/docs/doctoring/hr-workspace-position-lifecycle-review-accessibility-references.md
@@ -0,0 +1,20 @@
+# HR Workspace Position lifecycle review accessibility references
+
+Checked against the current W3C Recommendation baselines used by the existing HR Workspace interaction stack. These references support interaction semantics only; they do not establish Position mutation, staffing, or employment-decision authority.
+
+## Design implications
+
+- WAI-ARIA 1.2 defines `status` as advisory live-region information and `alert` as assertive information that does not itself require focus. Orgmetra therefore uses `status` for ordinary load/review/recording progress and `alert` for denial, stale evidence, staffing conflicts, and failures.
+- WAI-ARIA 1.2 defines `aria-busy` for content that is being updated. Orgmetra sets it while loading authoritative evidence or recording one human review, and the action is disabled to prevent a duplicate interaction.
+- WCAG 2.2 remains the normative WCAG Recommendation baseline used here. The interaction preserves visible keyboard focus, a 44px minimum action target in the existing design-token system, actionable non-color-only state copy, and explicit next actions.
+- The Figma `Orgmetra Baseline` Storybook Inventory node `1:64` explicitly requires `default / hover / focus / disabled / loading / validation-error / read-only / high-risk-confirmation` states. This slice implements those semantics for a Position lifecycle review without inventing a new geometry or application authority.
+
+## APA 7 references
+
+World Wide Web Consortium. (2023). *Accessible Rich Internet Applications (WAI-ARIA) 1.2* (W3C Recommendation). https://www.w3.org/TR/wai-aria-1.2/
+
+World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/
+
+## Product boundary
+
+High-risk confirmation in this Storybook evidence records the need for accountable human review; it does not execute a Position lifecycle mutation. PR #111 remains the review-evidence owner and PR #112 remains the authoritative application owner. Before consequential application, fresh bitemporal Position/Assignment evidence and staffing safety must be re-established by the authoritative Orgmetra boundary.
diff --git a/docs/traceability/hr-workspace-position-lifecycle-review-state.md b/docs/traceability/hr-workspace-position-lifecycle-review-state.md
new file mode 100644
index 000000000..e5ce3aacd
--- /dev/null
+++ b/docs/traceability/hr-workspace-position-lifecycle-review-state.md
@@ -0,0 +1,35 @@
+# HR Workspace Position lifecycle review state traceability
+
+## Truth boundary
+
+- This file is **active-PR evidence only** until the dependency stack integrates and is revalidated against fresh `develop`.
+- Parent UI contract: PR #130 owns shared protected-read accessibility semantics and Figma/Storybook correlation.
+- Position lifecycle governance owner: PR #111 owns the human-reviewed `PositionLifecycleChangeReviewPacket`; this UI does not recreate that evidence contract.
+- Authoritative application owner: dependency-first PR #112 owns the later Position lifecycle mutation boundary. This UI does not write Position/Assignment truth and does not authorize an application.
+
+## Buyer-visible requirement → evidence
+
+| Requirement | Active-PR evidence | Safety meaning |
+| --- | --- | --- |
+| Load fresh Position and Assignment evidence before review | `loading` → `review` state model and Storybook stories | Cached or locally invented lifecycle truth is not presented as sufficient evidence. |
+| Show a consequential review as explicit human confirmation | `review` uses `high-risk-confirmation` and a `Confirm lifecycle review` action | Confirmation records human review only; it does not apply, freeze, close, abolish, or reactivate a Position. |
+| Prevent duplicate review recording | `recording` is busy and disables the action | One in-flight review recording is visible and duplicate submission is blocked in the interaction model. |
+| Recorded review remains evidence only | `recorded` is read-only and disables the action | A completed review is not mutation authority. |
+| Stale authoritative evidence fails closed | `stale` is an assertive validation alert | The reviewer must reload fresh authoritative Position and Assignment evidence before another review. |
+| Staffing conflict fails closed | `blocked` is an assertive validation alert | The UI cannot override a staffing-safety boundary; fresh authoritative staffing evidence must be re-resolved. |
+| Denied reviewer/purpose scope fails closed | `denied` is an assertive permission alert | No local or cached downgrade path is offered. |
+| Service failure names a safe next action | `error` rejects cached review and instructs service/authorization verification | Customer-facing recovery copy does not fabricate evidence. |
+| Figma/Storybook state inventory stays inspectable | every rendered state carries `data-figma-node-id="1:64"`; Storybook exposes nine named states | Reviewers can inspect default, loading, high-risk-confirmation, read-only, permission-denied, validation-error, error and focus-related evidence. |
+| Owned interaction logic has exact coverage | focused workflow requires Node 24 100% line/branch/function coverage | Missing owned branches are non-passing. |
+
+## Data minimization
+
+The view model contains only constant governed copy and interaction semantics. It accepts no Person or candidate identity, worker name/contact data, compensation or salary values, ratings, assessment results, credentials or bearer tokens, prompts, model output, free-form HR notes, or Position/Assignment payload values.
+
+## Consequential-decision boundary
+
+A human confirmation in this UI is not a Position mutation and is not an employment decision. Before any authoritative change, the application boundary must freshly re-resolve same-tenant bitemporal Position and Assignment truth, the reviewed lifecycle evidence, staffing safety, actor separation/authority, effective/business time, system-recorded time, and immutable audit/outbox evidence. UI state never substitutes for those checks.
+
+## Verification and stack discipline
+
+The dedicated workflow runs the Position lifecycle interaction contract directly on Node 24, checks out the exact candidate head, requires exact 100% line/branch/function coverage, and proves a clean checkout. Parent #130, PR #111 and PR #112 checks/reviews never transfer to this child. After #130 actually integrates, retarget this child to fresh `develop`, reconcile any UI/design changes, and rerun every applicable browser/accessibility/Foundation/Recovery/SAST/Security and central required workflow on one resulting exact head.
diff --git a/tests/hr-workspace-position-lifecycle-review-state.test.mjs b/tests/hr-workspace-position-lifecycle-review-state.test.mjs
new file mode 100644
index 000000000..713616084
--- /dev/null
+++ b/tests/hr-workspace-position-lifecycle-review-state.test.mjs
@@ -0,0 +1,165 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import {
+ positionLifecycleReviewStateMarkup,
+ positionLifecycleReviewViewModel,
+} from '../apps/hr-workspace/position-lifecycle-review-state.js';
+
+const story = readFileSync(
+ new URL('../apps/hr-workspace/position-lifecycle-review-state.stories.js', import.meta.url),
+ 'utf8',
+);
+const css = readFileSync(
+ new URL('../apps/hr-workspace/position-lifecycle-review-state.css', import.meta.url),
+ 'utf8',
+);
+const traceability = readFileSync(
+ new URL('../docs/traceability/hr-workspace-position-lifecycle-review-state.md', import.meta.url),
+ 'utf8',
+);
+const doctoring = readFileSync(
+ new URL('../docs/doctoring/hr-workspace-position-lifecycle-review-accessibility-references.md', import.meta.url),
+ 'utf8',
+);
+const workflow = readFileSync(
+ new URL('../.github/workflows/hr-workspace-position-lifecycle-review-state.yml', import.meta.url),
+ 'utf8',
+);
+
+const states = [
+ 'idle',
+ 'loading',
+ 'review',
+ 'recording',
+ 'recorded',
+ 'denied',
+ 'stale',
+ 'blocked',
+ 'error',
+];
+
+test('Position lifecycle review requires a visible high-risk human confirmation state', () => {
+ const review = positionLifecycleReviewViewModel('review');
+ assert.equal(review.ariaBusy, 'false');
+ assert.equal(review.role, 'status');
+ assert.equal(review.submitDisabled, false);
+ assert.equal(review.interactionState, 'high-risk-confirmation');
+ assert.match(review.message, /does not apply/i);
+ assert.match(review.nextAction, /confirm/i);
+
+ const markup = positionLifecycleReviewStateMarkup('review');
+ assert.match(markup, /data-figma-node-id="1:64"/);
+ assert.match(markup, /data-interaction-state="high-risk-confirmation"/);
+ assert.match(markup, />Confirm lifecycle review);
+});
+
+test('loading and recording are busy and prevent duplicate review actions', () => {
+ for (const state of ['loading', 'recording']) {
+ const model = positionLifecycleReviewViewModel(state);
+ assert.equal(model.ariaBusy, 'true');
+ assert.equal(model.submitDisabled, true);
+ assert.equal(model.role, 'status');
+ assert.match(positionLifecycleReviewStateMarkup(state), /