Skip to main content

HR Cases — Operational Runbook

This is the operator's reference for the HR Case Management module (spec 28 + 28A) — what's running, what to check when something sticks, and how to recover.

What's running

SurfaceWhereTrigger
API endpointsapp/services/api/src/routes/hr-cases/Per request
Frontendapp/services/web/src/app/(app)/hr-cases/Per page load
hr_cases.ack_timeout.sweepworker.ts daily job, queue :policyDaily, every active tenant
hr_pip.lock_flip.sweepSameDaily
hr_pip.progress.snapshotSameDaily (7-day trailing window)
hr_cases.suspension.syncSameDaily
hr_cases.retention.purgeSameDaily
hr_case_auto_create actionworker.ts policy enforcementPer policy enforcement check

All workers are idempotent. Re-running them over the same day is safe.

State machine

A case moves through these states. Invalid transitions return 409.

DRAFT → OPEN → UNDER_REVIEW → PENDING_EMPLOYEE_ACKNOWLEDGEMENT → CLOSED
↘ CLOSED → APPEALED → CLOSED
↘ WITHDRAWN
↘ CLOSED (auto-close types: VERBAL_WARNING)

is_expunged = true is set on OVERTURNED appeal outcomes; expunged cases are excluded from every response below hr_admin.

Common problems

A case is stuck in PENDING_EMPLOYEE_ACKNOWLEDGEMENT

Three causes:

  1. Employee can't access the portal — check the employee's user account is active and email-joined to their employees row. HR can use POST /:id/acknowledge/record-refusal (or eventually record-physical once that endpoint ships) to close the case manually.

  2. Notification didn't deliver — check the notifications activity log filtered by event_type = 'hr_cases.acknowledgement.requested'. Re-trigger via the notification resend UI.

  3. 3-business-day timeout fired but no alert came through — verify the daily worker is running:

    SELECT MAX(occurred_at) FROM hr_case_events
    WHERE tenant_id = '<id>' AND event_type = 'TIMEOUT_ALERT_SENT';

    If the latest is days old, the worker isn't running. Check app-worker-1 container logs.

A SUSPENSION case opened but the agent still got scheduled

Daily reconciliation has a 24-hour lag. To force it:

docker exec app-worker-1 \
redis-cli -u "$REDIS_URL" LPUSH queue:policy \
'{"type":"hr_cases.suspension.sync","tenant_id":"<id>"}'

Then verify the block:

SELECT * FROM workforce_employee_blocks
WHERE tenant_id = '<id>' AND source = 'hr_cases.suspension'
AND removed_at IS NULL;

A document download fails with 422

The sealed-document SHA-256 verification fired. This means either:

  • The S3 object was modified out-of-band (security alert)
  • The hash on the row doesn't match what was uploaded (data corruption)

Check the worker logs for Document integrity check failed. The row isn't deleted automatically — investigate, then either restore from backup or expunge the case if the original is unrecoverable.

Approval chain is stuck

A tenant_admin can override:

POST /api/v1/hr-cases/:id/approvals/override
{ "override_reason": "<required>" }

Marks all PENDING approvals BYPASSED, inserts an is_override record, advances the case. Audit captures the override actor + reason.

An appeal was submitted past the deadline

The appeal_deadline column on hr_cases is the source of truth. Check it against closed_at:

SELECT id, closed_at, appeal_deadline
FROM hr_cases WHERE id = '<id>';

If the deadline computation looks wrong, verify the tenant's jurisdiction in compliance_jurisdictions has correct compliance_stat_holidays rows for the year.

Permissions

PermissionWho has it
hr_cases.case.createhr_admin, manager, supervisor
hr_cases.case.readhr_admin, tenant_admin
hr_cases.case.read.teammanager, supervisor
hr_cases.case.read.ownevery authenticated employee
hr_cases.case.investigation.readhr_admin, compliance_officer
hr_cases.approval.reviewhr_admin, manager
hr_cases.approval.overridetenant_admin only
hr_cases.appeal.createsubject employee on own case
hr_cases.appeal.reviewhr_admin, named appeal reviewer
hr_cases.document.uploadhr_admin, manager
hr_cases.document.readhr_admin, compliance_officer, manager (non-investigation)
hr_cases.document.sealhr_admin
hr_cases.type.managehr_admin, tenant_admin
hr_cases.report.readhr_admin, tenant_admin

Audit events

All privileged operations emit immutable events. Standard SELECT:

SELECT action, actor_id, occurred_at, metadata
FROM audit_events
WHERE tenant_id = '<id>'
AND action LIKE 'hr_cases.%'
ORDER BY occurred_at DESC LIMIT 50;

Full action list: hr_cases.case.{created,opened,closed,withdrawn, updated}, hr_cases.approval.{approved,rejected,overridden}, hr_cases.acknowledgement.{received,refused}, hr_cases.appeal.{submitted,outcome_recorded}, hr_cases.case.expunged, hr_cases.document.{uploaded,sealed,downloaded,generated}, hr_cases.suspension.{initiated,lifted}, hr_cases.type.{created,updated,deactivated}.

Retention

Per hr_case_types.document_retention_years (NULL → 7 years fallback). The retention worker runs daily and purges documents on CLOSED/WITHDRAWN cases past the cutoff. The case row itself is preserved; only the document attachments and S3 objects are deleted, plus a DOCUMENT_PURGED timeline event marks the action.

Known limits

  • No bulk import — migrations from BambooHR/Workday are manual SQL today
  • No async PDF gen — template→PDF is synchronous (returns 202 with job-shaped envelope so a future async move is non-breaking)
  • Schedule-publish enforcementworkforce_employee_blocks is populated, but only some assignment endpoints check it. As routes adopt isEmployeeBlockedOn(), the coverage tightens.
  • i18n — case-related strings aren't in the locale files
  • No witness statements as a first-class concept (use EVIDENCE document_type in the meantime)

Specs

Specs live in the repo at specs/ (raw markdown, not served by this docs site). On GitHub: