diff --git a/.github/workflows/hr-workspace-hiring-decision-record.yml b/.github/workflows/hr-workspace-hiring-decision-record.yml new file mode 100644 index 000000000..6f8d6a67d --- /dev/null +++ b/.github/workflows/hr-workspace-hiring-decision-record.yml @@ -0,0 +1,55 @@ +name: HR Workspace Hiring Decision Record State Quality + +on: + pull_request: + branches: + - develop + - feat/hr-workspace-protected-read-state + paths: + - "apps/hr-workspace/hiring-decision-record-state.js" + - "apps/hr-workspace/hiring-decision-record-state.css" + - "apps/hr-workspace/hiring-decision-record-state.stories.js" + - "tests/hr-workspace-hiring-decision-record.test.mjs" + - "docs/traceability/hr-workspace-hiring-decision-record.md" + - "docs/doctoring/hr-workspace-hiring-decision-record-accessibility-references.md" + - ".github/workflows/hr-workspace-hiring-decision-record.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: hr-workspace-hiring-decision-record-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + hiring-decision-record: + name: Hiring decision record 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 hiring-decision record 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-hiring-decision-record.test.mjs + - name: Require clean checkout + run: | + git diff --exit-code + test -z "$(git status --porcelain)" diff --git a/apps/hr-workspace/hiring-decision-record-state.css b/apps/hr-workspace/hiring-decision-record-state.css new file mode 100644 index 000000000..934deec54 --- /dev/null +++ b/apps/hr-workspace/hiring-decision-record-state.css @@ -0,0 +1,66 @@ +.hiring-decision-record-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); +} + +.hiring-decision-record-status, +.hiring-decision-record-confirmation, +.hiring-decision-record-evidence, +.hiring-decision-record-next-action { + display: grid; + gap: var(--orgmetra-space-xs); + margin: 0; +} + +.hiring-decision-record-status span, +.hiring-decision-record-confirmation span, +.hiring-decision-record-evidence span, +.hiring-decision-record-next-action span { + color: var(--orgmetra-text-muted); +} + +.hiring-decision-record-state[data-interaction-state="high-risk-confirmation"] { + border-color: var(--orgmetra-action-review); +} + +.hiring-decision-record-state[data-interaction-state="permission-denied"], +.hiring-decision-record-state[data-interaction-state="validation-error"], +.hiring-decision-record-state[data-interaction-state="error"] { + border-color: var(--orgmetra-danger); +} + +.hiring-decision-record-state[aria-busy="true"] { + cursor: progress; +} + +.hiring-decision-record-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; +} + +.hiring-decision-record-action:hover:not(:disabled) { + opacity: 0.88; +} + +.hiring-decision-record-action:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.hiring-decision-record-action:focus-visible { + outline: 3px solid var(--orgmetra-focus-ring); + outline-offset: 3px; +} diff --git a/apps/hr-workspace/hiring-decision-record-state.js b/apps/hr-workspace/hiring-decision-record-state.js new file mode 100644 index 000000000..e1035c5d5 --- /dev/null +++ b/apps/hr-workspace/hiring-decision-record-state.js @@ -0,0 +1,102 @@ +const STATE_MODELS = Object.freeze({ + idle: Object.freeze({ + ariaBusy: 'false', ariaLive: 'polite', role: 'status', actionDisabled: false, + humanConfirmationRequired: false, decisionRecorded: false, + interactionState: 'default', actionLabel: 'Load decision evidence', + label: 'Review hiring decision evidence', + message: 'Load fresh purpose-authorized candidate, Job, criterion, and decision evidence before recording a consequential hiring decision.', + nextAction: 'Load the current governed decision evidence before reviewing or confirming a hiring decision.', + }), + loading: Object.freeze({ + ariaBusy: 'true', ariaLive: 'polite', role: 'status', actionDisabled: true, + humanConfirmationRequired: false, decisionRecorded: false, + interactionState: 'loading', actionLabel: 'Loading decision evidence', + label: 'Loading hiring decision evidence', + message: 'Orgmetra is resolving the authorized candidate, Job, criterion, evidence-version, and decision-scope references for this purpose-bound review.', + nextAction: 'Wait for the governed hiring-decision evidence read to finish.', + }), + review: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', actionDisabled: false, + humanConfirmationRequired: true, decisionRecorded: false, + interactionState: 'high-risk-confirmation', actionLabel: 'Confirm and record decision', + label: 'Human confirmation required', + message: 'An accountable human must confirm this hiring decision with actor, purpose, reason, and evidence version. This presentation state does not authorize worker materialization.', + nextAction: 'Verify the criterion-linked evidence and limitations, then explicitly confirm the governed decision only if the evidence supports the accountable human judgment.', + }), + recording: Object.freeze({ + ariaBusy: 'true', ariaLive: 'polite', role: 'status', actionDisabled: true, + humanConfirmationRequired: false, decisionRecorded: false, + interactionState: 'loading', actionLabel: 'Recording confirmed decision', + label: 'Recording confirmed hiring decision', + message: 'Orgmetra is submitting the confirmed decision to the authoritative decision boundary. This in-progress state is not proof that the decision was recorded.', + nextAction: 'Do not resubmit or act on the outcome until the authoritative decision record and immutable audit evidence are returned.', + }), + recorded: Object.freeze({ + ariaBusy: 'false', ariaLive: 'polite', role: 'status', actionDisabled: true, + humanConfirmationRequired: false, decisionRecorded: true, + interactionState: 'read-only', actionLabel: 'Hiring decision recorded', + label: 'Hiring decision recorded', + message: 'The authoritative hiring decision record and immutable audit evidence were returned. This read-only UI does not itself create an offer, employment, or candidate-to-worker link.', + nextAction: 'Review the immutable decision evidence and continue only through the separately governed offer or confirmed-hire boundary that applies to this outcome.', + }), + denied: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', actionDisabled: false, + humanConfirmationRequired: false, decisionRecorded: false, + interactionState: 'permission-denied', actionLabel: 'Review access', + label: 'Hiring decision access denied', + message: 'The current actor or HR purpose does not permit this governed decision-record read or confirmation.', + nextAction: 'Check the HR purpose and decision-record authority before requesting or confirming a hiring decision again.', + }), + stale: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', actionDisabled: false, + humanConfirmationRequired: false, decisionRecorded: false, + interactionState: 'validation-error', actionLabel: 'Reload decision evidence', + label: 'Hiring decision evidence is stale', + message: 'Candidate, Job, criterion, evidence-version, or decision-scope truth changed before the decision could be safely confirmed.', + nextAction: 'Reload the current governed candidate, Job, and decision evidence before reviewing the hiring decision again.', + }), + evidenceBlocked: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', actionDisabled: false, + humanConfirmationRequired: false, decisionRecorded: false, + interactionState: 'validation-error', actionLabel: 'Return to evidence review', + label: 'Hiring decision evidence is incomplete', + message: 'The governed decision boundary cannot prove the required criterion-linked evidence, limitations, actor context, purpose, reason, or evidence version.', + nextAction: 'Return to evidence review, resolve the missing governed evidence, and start a new accountable confirmation from fresh authority.', + }), + error: Object.freeze({ + ariaBusy: 'false', ariaLive: 'assertive', role: 'alert', actionDisabled: false, + humanConfirmationRequired: false, decisionRecorded: false, + interactionState: 'error', actionLabel: 'Reconcile decision status', + label: 'Hiring decision status unavailable', + message: 'The decision submission did not return usable authoritative decision and audit evidence, so Orgmetra does not treat the hiring decision as recorded.', + nextAction: 'Reconcile the authoritative decision record and immutable audit evidence before retrying or taking any downstream hiring action.', + }), +}); + +function requireExactState(value) { + if (typeof value !== 'string') { + throw new TypeError('hiring-decision-record state must be an exact built-in string'); + } + if (!Object.hasOwn(STATE_MODELS, value)) { + throw new TypeError(`unsupported hiring-decision-record state: ${value}`); + } + return STATE_MODELS[value]; +} + +/** Return immutable accessibility semantics for one purpose-bound hiring-decision workflow state. */ +export function hiringDecisionRecordViewModel(state) { + return requireExactState(state); +} + +/** Render static Storybook workflow evidence without accepting caller-controlled candidate or decision values. */ +export function hiringDecisionRecordMarkup(state) { + const model = requireExactState(state); + const disabled = model.actionDisabled ? ' disabled' : ''; + const confirmationText = model.humanConfirmationRequired + ? 'Required before the governed decision may be submitted.' + : 'Not available in this workflow state.'; + const recordedText = model.decisionRecorded + ? 'Authoritative decision and immutable audit evidence returned.' + : 'No recorded decision is asserted by this workflow state.'; + return `
\n

${model.label}${model.message}

\n

Human confirmation${confirmationText}

\n

Decision evidence${recordedText}

\n

Next action${model.nextAction}

\n \n
`; +} diff --git a/apps/hr-workspace/hiring-decision-record-state.stories.js b/apps/hr-workspace/hiring-decision-record-state.stories.js new file mode 100644 index 000000000..b99e8c6dd --- /dev/null +++ b/apps/hr-workspace/hiring-decision-record-state.stories.js @@ -0,0 +1,26 @@ +import { hiringDecisionRecordMarkup } from './hiring-decision-record-state.js'; +import './hiring-decision-record-state.css'; + +export default { + title: 'HR Workspace/Hiring Decision Record States', + parameters: { + design: { + type: 'figma', + url: 'Orgmetra Baseline — Recruiting Workspace node 1:22 / Storybook Inventory node 1:64 / DecisionRecord', + }, + }, +}; + +function story(state) { + return () => hiringDecisionRecordMarkup(state); +} + +export const Idle = story('idle'); +export const Loading = story('loading'); +export const HumanConfirmation = story('review'); +export const Recording = story('recording'); +export const RecordedReadOnly = story('recorded'); +export const PermissionDenied = story('denied'); +export const StaleEvidence = story('stale'); +export const EvidenceBlocked = story('evidenceBlocked'); +export const Error = story('error'); diff --git a/docs/doctoring/hr-workspace-hiring-decision-record-accessibility-references.md b/docs/doctoring/hr-workspace-hiring-decision-record-accessibility-references.md new file mode 100644 index 000000000..b651a5220 --- /dev/null +++ b/docs/doctoring/hr-workspace-hiring-decision-record-accessibility-references.md @@ -0,0 +1,30 @@ +# Hiring decision record interaction and evidence references + +Reviewed: 2026-08-28 (Asia/Seoul). + +This note records primary standards used by the active Hiring decision record presentation slice. It is engineering/design evidence only. It does not claim WCAG certification, legal compliance, professional validation of a selection procedure, or authority for an autonomous hiring decision. + +## Primary standards + +American Educational Research Association, American Psychological Association, & National Council on Measurement in Education. (2014). *Standards for educational and psychological testing*. American Educational Research Association. https://www.testingstandards.net/open-access-files.html + +Equal Employment Opportunity Commission. (1978). *Uniform guidelines on employee selection procedures*, 29 C.F.R. Part 1607. https://www.eeoc.gov/regulations-and-guidelines + +Society for Industrial and Organizational Psychology. (2018). Principles for the validation and use of personnel selection procedures. *Industrial and Organizational Psychology, 11*(S1), 1–97. https://doi.org/10.1017/iop.2018.195 + +World Wide Web Consortium. (2023, October 5). *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/ + +## Design consequences + +- A hiring decision is a high-impact accountable human action. The interaction requires explicit confirmation after the actor, purpose, reason, evidence version, criterion-linked evidence, and limitations are visible from authoritative governed sources. +- The presentation state never converts an assessment score, interview result, model output, or matching signal into decision authority. It also never creates an offer, Employment, or candidate-to-worker link. +- Recording is fail-closed: an in-progress submission is not treated as a recorded decision. Only the separately authoritative decision boundary plus immutable audit evidence can establish a recorded outcome. +- Recorded state is read-only. Corrections or downstream offer/hire actions must use their separately governed boundaries rather than mutating presentation state. +- Loading and recording expose `aria-busy` and disable duplicate actions. Denied, stale, evidence-blocked, and error states use alert semantics and provide a concrete next action. +- Generic interaction evidence is value-minimized and contains no candidate/Person identity, Job/requisition/application identifier, raw evidence, score, decision code/outcome, rating, compensation, credential, token, prompt, or model output. +- Interactive actions preserve visible `:focus-visible` treatment and a 44-pixel minimum target height using existing Orgmetra design tokens. +- Fresh Figma `Orgmetra Baseline` metadata was read on 2026-08-28. Recruiting Workspace node `1:22` names `Decision record with criterion evidence`; Storybook Inventory node `1:64` lists `DecisionRecord` and requires default, hover, focus, disabled, loading, validation-error, read-only, and high-risk-confirmation states. + +The active PR remains dependency-first. Focused child evidence is not shipped-product, legal-compliance, or selection-validity evidence. diff --git a/docs/traceability/hr-workspace-hiring-decision-record.md b/docs/traceability/hr-workspace-hiring-decision-record.md new file mode 100644 index 000000000..6a31af98a --- /dev/null +++ b/docs/traceability/hr-workspace-hiring-decision-record.md @@ -0,0 +1,42 @@ +# HR Workspace Hiring decision record traceability + +Status: **active PR evidence only**. This document does not change protected-main shipped truth and does not authorize an employment decision. + +## Ownership boundary + +- Parent PR #130 owns the shared HR Workspace protected-read interaction semantics and Figma/Storybook accessibility contract. +- This dependency-first child owns only the Recruiting Workspace / `DecisionRecord` presentation and workflow-state shell. +- Existing Orgmetra selection-review/selection-decision evidence owners remain authoritative for decision evidence. Offer approval/response, confirmed-hire authority, People/Employment mutation, and candidate-to-worker conversion remain separate governed owners. +- This UI does not create a new selection algorithm, assessment owner, offer authority, hire authority, Employment mutation path, or candidate-to-worker linkage path. +- Dedicated-writer CWL repositories remain read-only dependencies and are not mutated by this slice. + +## Buyer-visible contract + +Protected PRD P1 names the Hiring decision record. Protected wireframes place the Selection decision record in Recruiting Workspace. Fresh Figma `Orgmetra Baseline` metadata identifies Recruiting Workspace node `1:22` with `Decision record with criterion evidence`, and Storybook Inventory node `1:64` lists `DecisionRecord`. + +This slice provides bounded executable states: + +`idle / loading / review / recording / recorded / denied / stale / evidenceBlocked / error`. + +`review` is a high-risk-confirmation state. It requires an accountable human to verify criterion-linked evidence and limitations and explicitly confirm actor, purpose, reason, and evidence version. `recording` disables duplicate submission and is explicitly not proof of persistence. `recorded` is read-only and is asserted only after the authoritative decision boundary returns immutable audit evidence. No state itself creates an offer, Employment, or candidate-to-worker link. + +Denied, stale, evidence-blocked, and error states fail closed with concrete next actions. Generic state payloads contain no candidate/Person identity, Job/requisition/application identifier, raw evidence, decision code/outcome, selection/assessment/interview score, rating, compensation, credential/token, prompt, or model output. Unsupported runtime values and prototype-inherited state names such as `constructor`, `toString`, and `__proto__` are rejected through exact primitive type plus own-key membership checks. + +## Design and standards evidence + +Fresh Figma `Orgmetra Baseline` metadata was read on 2026-08-28. Node `1:22` names the Recruiting Workspace decision record with criterion evidence. Storybook Inventory node `1:64` continues to require default, hover, focus, disabled, loading, validation-error, read-only, and high-risk-confirmation behavior. The implementation reuses existing Orgmetra tokens and Storybook rather than creating a parallel design system. + +WCAG 2.2, WAI-ARIA 1.2, the *Standards for Educational and Psychological Testing*, the Uniform Guidelines on Employee Selection Procedures, and SIOP's fifth-edition personnel-selection principles are recorded under `docs/doctoring/hr-workspace-hiring-decision-record-accessibility-references.md`. These sources constrain interaction and evidence interpretation; they are not evidence that an individual procedure or decision is valid or lawful. + +## Verification contract + +`.github/workflows/hr-workspace-hiring-decision-record.yml` must: + +1. check out and prove the exact candidate SHA; +2. use the reviewed Node 24 toolchain; +3. execute the focused interaction/privacy/fail-closed regression with exact 100% line, branch, and function coverage; and +4. finish with a clean checkout. + +Contract-only head `b721e46b11612733026139da0a41bb16293d77b7` produced genuine hosted RED: run `33162121276`, job `98818811945` checked out and proved that exact SHA, set up Node, then failed at the focused hiring-decision record contract while production `apps/hr-workspace/hiring-decision-record-state.js` was intentionally absent. The clean-checkout step was correctly skipped after the focused failure. + +Focused child GREEN remains stack-local only. After #53 and #130 integrate, this child must be retargeted/reconciled against fresh `develop` and all applicable browser/accessibility/Foundation/Recovery/SAST/Security and central required workflows must execute again on one resulting exact head. Parent or predecessor checks/reviews never transfer. diff --git a/tests/hr-workspace-hiring-decision-record.test.mjs b/tests/hr-workspace-hiring-decision-record.test.mjs new file mode 100644 index 000000000..4c30fe814 --- /dev/null +++ b/tests/hr-workspace-hiring-decision-record.test.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; + +import { + hiringDecisionRecordMarkup, + hiringDecisionRecordViewModel, +} from '../apps/hr-workspace/hiring-decision-record-state.js'; + +const STATES = Object.freeze([ + 'idle', + 'loading', + 'review', + 'recording', + 'recorded', + 'denied', + 'stale', + 'evidenceBlocked', + 'error', +]); + +const ALLOWED_KEYS = Object.freeze([ + 'actionDisabled', + 'actionLabel', + 'ariaBusy', + 'ariaLive', + 'decisionRecorded', + 'humanConfirmationRequired', + 'interactionState', + 'label', + 'message', + 'nextAction', + 'role', +]); + +const FORBIDDEN_VALUE_KEYS = Object.freeze([ + 'candidateId', + 'candidateName', + 'candidateEmail', + 'personId', + 'jobId', + 'requisitionId', + 'applicationId', + 'decisionCode', + 'decisionOutcome', + 'selectionScore', + 'assessmentScore', + 'interviewScore', + 'rating', + 'compensation', + 'rawEvidence', + 'credential', + 'token', + 'prompt', + 'modelOutput', +]); + +const WORKFLOW = readFileSync(new URL('../.github/workflows/hr-workspace-hiring-decision-record.yml', import.meta.url), 'utf8'); +const CSS = readFileSync(new URL('../apps/hr-workspace/hiring-decision-record-state.css', import.meta.url), 'utf8'); + +test('hiring decision record exposes only bounded governed workflow states', () => { + for (const state of STATES) { + const model = hiringDecisionRecordViewModel(state); + assert.deepEqual(Object.keys(model).sort(), [...ALLOWED_KEYS].sort()); + assert.equal(typeof model.nextAction, 'string'); + assert.ok(model.nextAction.length > 0); + assert.equal(typeof model.humanConfirmationRequired, 'boolean'); + assert.equal(typeof model.decisionRecorded, 'boolean'); + } + + assert.equal(hiringDecisionRecordViewModel('loading').ariaBusy, 'true'); + assert.equal(hiringDecisionRecordViewModel('loading').actionDisabled, true); + assert.equal(hiringDecisionRecordViewModel('review').interactionState, 'high-risk-confirmation'); + assert.equal(hiringDecisionRecordViewModel('review').humanConfirmationRequired, true); + assert.equal(hiringDecisionRecordViewModel('recording').actionDisabled, true); + assert.equal(hiringDecisionRecordViewModel('recorded').interactionState, 'read-only'); + assert.equal(hiringDecisionRecordViewModel('recorded').decisionRecorded, true); + assert.equal(hiringDecisionRecordViewModel('denied').role, 'alert'); +}); + +test('hiring decision workflow is value-minimized and never creates shadow decision authority', () => { + for (const state of STATES) { + const model = hiringDecisionRecordViewModel(state); + for (const forbiddenKey of FORBIDDEN_VALUE_KEYS) { + assert.equal(Object.hasOwn(model, forbiddenKey), false); + } + } + + const review = hiringDecisionRecordViewModel('review'); + assert.match(review.message, /accountable human/i); + assert.match(review.message, /actor, purpose, reason, and evidence version/i); + assert.match(review.message, /does not authorize worker materialization/i); + + const recording = hiringDecisionRecordViewModel('recording'); + assert.match(recording.message, /not proof that the decision was recorded/i); + + const recorded = hiringDecisionRecordViewModel('recorded'); + assert.match(recorded.message, /read-only/i); + assert.match(recorded.message, /immutable audit evidence/i); + assert.match(recorded.message, /does not itself create an offer, employment, or candidate-to-worker link/i); +}); + +test('hiring decision record renders Figma-correlated accessible DecisionRecord evidence', () => { + assert.match(CSS, /:hover:not\(:disabled\)/); + assert.match(CSS, /:focus-visible/); + const loading = hiringDecisionRecordMarkup('loading'); + assert.match(loading, /data-figma-node-id="1:64"/); + assert.match(loading, /data-figma-component="DecisionRecord"/); + assert.match(loading, /aria-busy="true"/); + assert.match(loading, /disabled/); + + const review = hiringDecisionRecordMarkup('review'); + assert.match(review, /data-interaction-state="high-risk-confirmation"/); + assert.match(review, /data-human-confirmation-required="true"/); + assert.match(review, /Human confirmation/); + + const recorded = hiringDecisionRecordMarkup('recorded'); + assert.match(recorded, /data-interaction-state="read-only"/); + assert.match(recorded, /data-decision-recorded="true"/); + assert.match(recorded, /role="status"/); + assert.match(recorded, /Next action/); +}); + +test('hiring decision record rejects non-string and prototype-inherited state names', () => { + for (const invalid of [null, 1, {}, [], new String('recorded')]) { + assert.throws(() => hiringDecisionRecordViewModel(invalid), TypeError); + assert.throws(() => hiringDecisionRecordMarkup(invalid), TypeError); + } + + for (const inheritedName of ['constructor', 'toString', '__proto__']) { + assert.throws(() => hiringDecisionRecordViewModel(inheritedName), TypeError); + assert.throws(() => hiringDecisionRecordMarkup(inheritedName), TypeError); + } + + assert.throws(() => hiringDecisionRecordViewModel('unknown'), TypeError); +}); + +test('hiring decision record fails closed with a concrete next action', () => { + const expectations = { + denied: /check the HR purpose and decision-record authority/i, + stale: /reload the current governed candidate, Job, and decision evidence/i, + evidenceBlocked: /return to evidence review/i, + error: /reconcile the authoritative decision record and immutable audit evidence/i, + }; + + for (const [state, pattern] of Object.entries(expectations)) { + const model = hiringDecisionRecordViewModel(state); + assert.equal(model.actionDisabled, false); + assert.equal(model.decisionRecorded, false); + assert.match(model.nextAction, pattern); + } +}); + +test('hiring decision record contract runs on protected develop and dependency parent pull requests', () => { + assert.match(WORKFLOW, /branches:\n\s+- develop\n\s+- feat\/hr-workspace-protected-read-state/); +});