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 ``; +} 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, /]* disabled/); + else assert.doesNotMatch(markup, /]* disabled/); + } +}); + +test('ready evidence preserves Employment, legal Organization, and bitemporal boundaries', () => { + const ready = legalEmployerHistoryViewModel('ready'); + assert.match(ready.message, /read-only bitemporal legal-employer history/i); + assert.match(ready.message, /Employment/i); + assert.match(ready.message, /legal Organization/i); + assert.match(ready.message, /effective time/i); + assert.match(ready.message, /system-recorded time/i); + assert.match(ready.message, /independent of Position and Assignment/i); + assert.match(ready.message, /does not authorize/i); + assert.match(ready.nextAction, /separately authorized change/i); +}); + +test('empty, stale, and scope-blocked states prevent unsafe legal-employer inference', () => { + assert.match(legalEmployerHistoryViewModel('empty').message, /not evidence of no Employment/i); + assert.match(legalEmployerHistoryViewModel('empty').message, /outside this coordinate/i); + assert.match(legalEmployerHistoryViewModel('stale').nextAction, /Reload/i); + assert.match(legalEmployerHistoryViewModel('scopeBlocked').nextAction, /Narrow the requested fields/i); + assert.match(legalEmployerHistoryViewModel('error').nextAction, /Do not infer/i); +}); + +test('unsupported and prototype-inherited runtime state names fail closed before rendering', () => { + for (const value of ['current', 'constructor', 'toString', '__proto__']) { + assert.throws(() => legalEmployerHistoryViewModel(value), /unsupported legal-employer-history state/); + assert.throws(() => legalEmployerHistoryStateMarkup(value), /unsupported legal-employer-history state/); + } + assert.throws(() => legalEmployerHistoryViewModel(new String('ready')), /exact built-in string/); + assert.throws(() => legalEmployerHistoryStateMarkup(Symbol('ready')), /exact built-in string/); +}); + +test('Storybook and CSS cover governed legal-employer accessibility states', () => { + for (const storyName of [ + 'Idle', 'Loading', 'ReadyReadOnly', 'Empty', 'PermissionDenied', + 'StaleEvidence', 'ScopeBlocked', 'Error', + ]) { + assert.match(story, new RegExp(`export const ${storyName}`)); + } + assert.match(story, /legalEmployerHistoryStateMarkup/); + assert.match(css, /var\(--orgmetra-focus-ring\)/); + assert.match(css, /:focus-visible/); + assert.match(css, /:hover:not\(:disabled\)/); + assert.match(css, /\[aria-busy="true"\]/); + assert.match(css, /read-only/); + assert.match(css, /permission-denied/); + assert.match(css, /validation-error/); + assert.match(css, /min-height:\s*44px/); +});