diff --git a/.github/workflows/hr-workspace-document-retrieval-state.yml b/.github/workflows/hr-workspace-document-retrieval-state.yml new file mode 100644 index 000000000..32dc3c396 --- /dev/null +++ b/.github/workflows/hr-workspace-document-retrieval-state.yml @@ -0,0 +1,54 @@ +name: HR Workspace Document Retrieval State Quality + +on: + pull_request: + branches: + - feat/hr-workspace-protected-read-state + paths: + - "apps/hr-workspace/document-retrieval-state.js" + - "apps/hr-workspace/document-retrieval-state.css" + - "apps/hr-workspace/document-retrieval-state.stories.js" + - "tests/hr-workspace-document-retrieval-state.test.mjs" + - "docs/traceability/hr-workspace-document-retrieval-state.md" + - "docs/doctoring/hr-workspace-document-retrieval-accessibility-references.md" + - ".github/workflows/hr-workspace-document-retrieval-state.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: hr-workspace-document-retrieval-state-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + document-retrieval-state: + name: Document retrieval 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 document-retrieval 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-document-retrieval-state.test.mjs + - name: Require clean checkout + run: | + git diff --exit-code + test -z "$(git status --porcelain)" diff --git a/apps/hr-workspace/document-retrieval-state.css b/apps/hr-workspace/document-retrieval-state.css new file mode 100644 index 000000000..3cdba037c --- /dev/null +++ b/apps/hr-workspace/document-retrieval-state.css @@ -0,0 +1,54 @@ +.document-retrieval-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); +} + +.document-retrieval-status, +.document-retrieval-next-action { + display: grid; + gap: var(--orgmetra-space-xs); + margin: 0; +} + +.document-retrieval-status span, +.document-retrieval-next-action span { + color: var(--orgmetra-text-muted); +} + +.document-retrieval-state[data-interaction-state="permission-denied"], +.document-retrieval-state[data-interaction-state="validation-error"], +.document-retrieval-state[data-interaction-state="error"] { + border-color: var(--orgmetra-danger); +} + +.document-retrieval-state[aria-busy="true"] { + cursor: progress; +} + +.document-retrieval-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; +} + +.document-retrieval-submit:disabled { + cursor: wait; + opacity: 0.62; +} + +.document-retrieval-submit:focus-visible { + outline: 3px solid var(--orgmetra-focus-ring); + outline-offset: 3px; +} diff --git a/apps/hr-workspace/document-retrieval-state.js b/apps/hr-workspace/document-retrieval-state.js new file mode 100644 index 000000000..6a96bb0d2 --- /dev/null +++ b/apps/hr-workspace/document-retrieval-state.js @@ -0,0 +1,114 @@ +const STATE_MODELS = Object.freeze({ + idle: Object.freeze({ + ariaBusy: 'false', + ariaLive: 'polite', + role: 'status', + submitDisabled: false, + interactionState: 'default', + label: 'Review access purpose before retrieval', + message: 'Choose the approved purpose and confirm that this authenticated HR session is appropriate for the document read.', + nextAction: 'Start one authorization request after confirming the purpose and access scope.', + }), + authorizing: Object.freeze({ + ariaBusy: 'true', + ariaLive: 'polite', + role: 'status', + submitDisabled: true, + interactionState: 'loading', + label: 'Authorizing document access', + message: 'Orgmetra is resolving the current document scope and purpose-bound authorization before any protected artifact read.', + nextAction: 'Wait for authorization to finish; do not start another retrieval.', + }), + reading: Object.freeze({ + ariaBusy: 'true', + ariaLive: 'polite', + role: 'status', + submitDisabled: true, + interactionState: 'loading', + label: 'Verifying protected document', + message: 'Orgmetra is performing the bounded artifact read and verifying the expected SHA-256 before release.', + nextAction: 'Wait while the authorized artifact is verified.', + }), + auditing: Object.freeze({ + ariaBusy: 'true', + ariaLive: 'polite', + role: 'status', + submitDisabled: true, + interactionState: 'loading', + label: 'Recording immutable access evidence', + message: 'The protected artifact has been verified; Orgmetra must append immutable access evidence before bytes are released.', + nextAction: 'Wait for the immutable access record to complete.', + }), + ready: Object.freeze({ + ariaBusy: 'false', + ariaLive: 'polite', + role: 'status', + submitDisabled: false, + interactionState: 'read-only', + label: 'Authorized document is ready', + message: 'Artifact verification and immutable access evidence completed. This read-only handoff does not authorize export or an employment decision.', + nextAction: 'Open the authorized document only in the authenticated HR session.', + }), + denied: Object.freeze({ + ariaBusy: 'false', + ariaLive: 'assertive', + role: 'alert', + submitDisabled: false, + interactionState: 'permission-denied', + label: 'Document access denied', + message: 'The current document, purpose, requester, or delivery scope was not authorized.', + nextAction: 'Review the purpose and access scope before starting a new authorization request.', + }), + stale: Object.freeze({ + ariaBusy: 'false', + ariaLive: 'assertive', + role: 'alert', + submitDisabled: false, + interactionState: 'validation-error', + label: 'Authorization expired before release', + message: 'The prior authorization is no longer current, so Orgmetra did not release the protected document.', + nextAction: 'Start a new authorization request; do not reuse the expired decision.', + }), + error: Object.freeze({ + ariaBusy: 'false', + ariaLive: 'assertive', + role: 'alert', + submitDisabled: false, + interactionState: 'error', + label: 'Document retrieval unavailable', + message: 'The protected artifact or immutable audit boundary did not return a usable result, and no local fallback is allowed.', + nextAction: 'Do not use a cached copy; check the protected source and audit service before trying again.', + }), +}); + +function requireExactState(value) { + if (typeof value !== 'string') { + throw new TypeError('document retrieval state must be an exact built-in string'); + } + if (!Object.hasOwn(STATE_MODELS, value)) { + throw new TypeError(`unsupported document retrieval state: ${value}`); + } + return STATE_MODELS[value]; +} + +/** + * Return value-minimized accessibility semantics for one HR document retrieval state. + * The view model contains workflow guidance only and never carries protected document bytes or HR values. + * @param {string} state Governed document retrieval state. + * @returns {Readonly} Immutable interaction semantics. + */ +export function documentRetrievalViewModel(state) { + return requireExactState(state); +} + +/** + * Render Storybook evidence for one governed HR document retrieval state. + * No caller-controlled protected value is accepted by this renderer. + * @param {string} state Governed document retrieval state. + * @returns {string} Static HTML for the HR Workspace Storybook fixture. + */ +export function documentRetrievalStateMarkup(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/document-retrieval-state.stories.js b/apps/hr-workspace/document-retrieval-state.stories.js new file mode 100644 index 000000000..d5a6c77b9 --- /dev/null +++ b/apps/hr-workspace/document-retrieval-state.stories.js @@ -0,0 +1,20 @@ +import './document-retrieval-state.css'; +import { documentRetrievalStateMarkup } from './document-retrieval-state.js'; + +export default { + title: 'HR Workspace/Document Retrieval State', + tags: ['autodocs'], +}; + +function story(state) { + return () => documentRetrievalStateMarkup(state); +} + +export const Idle = story('idle'); +export const Authorizing = story('authorizing'); +export const Reading = story('reading'); +export const Auditing = story('auditing'); +export const ReadyReadOnly = story('ready'); +export const PermissionDenied = story('denied'); +export const AuthorizationExpired = story('stale'); +export const Error = story('error'); diff --git a/docs/doctoring/hr-workspace-document-retrieval-accessibility-references.md b/docs/doctoring/hr-workspace-document-retrieval-accessibility-references.md new file mode 100644 index 000000000..56282228c --- /dev/null +++ b/docs/doctoring/hr-workspace-document-retrieval-accessibility-references.md @@ -0,0 +1,19 @@ +# HR Workspace document retrieval accessibility references + +Status: primary-source design references for active PR #132. These sources support interaction semantics only; they do not grant HR authorization, legal entitlement, export authority, or employment-decision authority. + +## References (APA 7) + +World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium. (2023). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/ + +## Applied interpretation + +- Progress messages that do not require immediate intervention use a polite `status` live region. +- Permission denial, expired authorization, and retrieval/audit failure use an assertive `alert` because the user's requested protected action cannot continue. +- `aria-busy=true` is bound only to authorizing, reading, and auditing states; duplicate submission is disabled while those phases are active. +- Completion is explicitly read-only and tells the HR user what to do next without implying export or employment-decision authority. +- Visible keyboard focus uses the existing Orgmetra `--orgmetra-focus-ring` token and `:focus-visible` contract inherited from the parent protected-read interaction slice. + +Primary references were rechecked on 2026-08-28. WCAG 2.2 remains the final W3C Recommendation used here; ongoing WCAG 2.x errata/editorial work is not treated as a new conformance version. diff --git a/docs/traceability/hr-workspace-document-retrieval-state.md b/docs/traceability/hr-workspace-document-retrieval-state.md new file mode 100644 index 000000000..ca75ebdae --- /dev/null +++ b/docs/traceability/hr-workspace-document-retrieval-state.md @@ -0,0 +1,33 @@ +# HR Workspace document retrieval interaction-state traceability + +## Status boundary + +- **Protected-main truth:** `develop@9e3e4847510e1e612b48474ba42b177b8ed824df` does not contain this workflow-specific UI state contract. +- **Parent active PR:** #130 owns the shared protected-read accessibility/Storybook state semantics and Figma correlation. This child reuses that interaction vocabulary rather than creating a second generic protected-read renderer. +- **Backend active PR:** #116 owns a separate purpose-bound HR document retrieval execution boundary. This UI child does not import that unmerged package and does not claim that a UI state is authorization. +- **This active PR:** adds only value-minimized HR document retrieval interaction evidence for authorization, bounded artifact verification, immutable audit-before-release, read-only handoff, denial, expiry, and transport/audit failure. + +## Buyer-facing contract + +| UI state | Meaning | Safe next action | Authority boundary | +| --- | --- | --- | --- | +| `idle` | No protected operation is in flight. | Confirm purpose/scope, then start one authorization request. | No read authority exists. | +| `authorizing` | Current tenant/document/purpose scope is being authorized. | Wait; duplicate retrieval is disabled. | No artifact read is implied. | +| `reading` | A bounded protected artifact read and SHA-256 verification is in flight. | Wait for verification. | UI does not receive or persist document bytes as state evidence. | +| `auditing` | Verified artifact is waiting for immutable access evidence. | Wait for the audit append. | Bytes are not considered releasable before durable audit succeeds. | +| `ready` | Verified, audited read is ready inside the authenticated HR session. | Open only in that authenticated session. | Read-only; not export or employment-decision authority. | +| `denied` | Purpose/requester/document/delivery scope was not authorized. | Review purpose and access scope before a new authorization attempt. | Fail closed. | +| `stale` | Authorization expired before release. | Start a new authorization request. | Expired decisions are never reused. | +| `error` | Protected source or audit boundary failed. | Check protected source/audit service; do not use cached data. | No local fallback. | + +All rendered state evidence is constant application copy. It does not contain document bytes/text/title, names, email/phone data, compensation, ratings, credentials/tokens, model output, or another service's application-table values. + +## Design and accessibility trace + +The parent #130 maps existing Figma `Orgmetra Baseline` Storybook Inventory node `1:64` to executable loading, disabled, error, read-only and focus semantics. This child adds workflow-specific stories without changing that design authority. In-progress phases use `aria-busy=true` and disable duplicate submission; denial/expiry/error use assertive alert semantics; normal progress/read-only states use polite status semantics; keyboard focus remains visible through the existing Orgmetra focus-ring token. + +## Verification + +`tests/hr-workspace-document-retrieval-state.test.mjs` requires all eight states, value-minimized copy, safe next-action copy, exact runtime-state rejection, Figma correlation, Storybook inventory, visible-focus styling and exact 100% line/branch/function coverage through the dedicated workflow. + +Stack-local GREEN never transfers to #130, #53, #116, or protected-main integration. After dependencies integrate, retarget/revalidate against fresh `develop` and rerun every applicable browser/accessibility/Foundation/SAST/Security/Recovery/central gate on one exact head. diff --git a/tests/hr-workspace-document-retrieval-state.test.mjs b/tests/hr-workspace-document-retrieval-state.test.mjs new file mode 100644 index 000000000..1c2484fac --- /dev/null +++ b/tests/hr-workspace-document-retrieval-state.test.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { + documentRetrievalStateMarkup, + documentRetrievalViewModel, +} from '../apps/hr-workspace/document-retrieval-state.js'; + +const story = readFileSync( + new URL('../apps/hr-workspace/document-retrieval-state.stories.js', import.meta.url), + 'utf8', +); +const css = readFileSync( + new URL('../apps/hr-workspace/document-retrieval-state.css', import.meta.url), + 'utf8', +); + +const expectedStates = { + idle: ['false', 'status', false, 'default', 'Review access purpose before retrieval'], + authorizing: ['true', 'status', true, 'loading', 'Authorizing document access'], + reading: ['true', 'status', true, 'loading', 'Verifying protected document'], + auditing: ['true', 'status', true, 'loading', 'Recording immutable access evidence'], + ready: ['false', 'status', false, 'read-only', 'Authorized document is ready'], + denied: ['false', 'alert', false, 'permission-denied', 'Document access denied'], + stale: ['false', 'alert', false, 'validation-error', 'Authorization expired before release'], + error: ['false', 'alert', false, 'error', 'Document retrieval unavailable'], +}; + +test('document retrieval states are bounded, actionable, and value-minimized', () => { + for (const [state, [ariaBusy, role, submitDisabled, interactionState, label]] of Object.entries(expectedStates)) { + const model = documentRetrievalViewModel(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, /\.$/); + + const serialized = JSON.stringify(model); + assert.doesNotMatch(serialized, /document_bytes|document_text|display_name|email|phone|compensation|rating|credential|token/i); + + const markup = documentRetrievalStateMarkup(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, /]* disabled/); + else assert.doesNotMatch(markup, /]* disabled/); + } +}); + +test('ready remains an audited read-only handoff rather than mutation or export authority', () => { + const ready = documentRetrievalViewModel('ready'); + assert.equal(ready.interactionState, 'read-only'); + assert.match(ready.message, /immutable access evidence/i); + assert.match(ready.message, /does not authorize export or an employment decision/i); + assert.match(ready.nextAction, /Open the authorized document only in the authenticated HR session\./); +}); + +test('denial, stale authorization, and transport failure explain the next safe action', () => { + assert.match(documentRetrievalViewModel('denied').nextAction, /purpose and access scope/i); + assert.match(documentRetrievalViewModel('stale').nextAction, /Start a new authorization request/i); + assert.match(documentRetrievalViewModel('error').nextAction, /Do not use a cached copy/i); +}); + +test('unsupported runtime input fails closed before rendering', () => { + assert.throws(() => documentRetrievalViewModel('retrying'), /unsupported document retrieval state/); + assert.throws(() => documentRetrievalViewModel(new String('ready')), /exact built-in string/); + assert.throws(() => documentRetrievalStateMarkup(Symbol('ready')), /exact built-in string/); +}); + +test('prototype-inherited names cannot masquerade as governed retrieval states', () => { + for (const inheritedName of ['constructor', 'toString', '__proto__']) { + assert.throws( + () => documentRetrievalViewModel(inheritedName), + /unsupported document retrieval state/, + `${inheritedName} must fail closed at the view-model boundary`, + ); + assert.throws( + () => documentRetrievalStateMarkup(inheritedName), + /unsupported document retrieval state/, + `${inheritedName} must fail closed before markup is emitted`, + ); + } +}); + +test('Storybook and CSS cover workflow-specific accessibility states', () => { + for (const storyName of [ + 'Idle', + 'Authorizing', + 'Reading', + 'Auditing', + 'ReadyReadOnly', + 'PermissionDenied', + 'AuthorizationExpired', + 'Error', + ]) { + assert.match(story, new RegExp(`export const ${storyName}`)); + } + assert.match(story, /documentRetrievalStateMarkup/); + assert.match(css, /var\(--orgmetra-focus-ring\)/); + assert.match(css, /:focus-visible/); + assert.match(css, /\[aria-busy="true"\]/); +});