Skip to main content

HR Foundation

Module path: apps/backend/src/hr/ Feature branch: 056-hr-foundation Spec: specs/056-hr-foundation/spec.md Design authority: docs/design/hr-payroll-attendance.md (§11, Phase A)

The employee system of record, and the country-abstraction layer that payroll (Phase B) and attendance (Phase D) consume. It ships no payroll calculation and no time capture, deliberately.


Architecture​

Four layers, the same hexagonal shape as every other feature module. Cross-module access goes through application services, never another module's repository.

apps/backend/src/hr/
├── hr.module.ts
├── domain/ # ports; no framework imports
│ ├── *-repository.domain.ts # one interface + token per aggregate
│ ├── encryption.port.ts # HR_ENCRYPTION_PORT
│ ├── identifier-hmac.port.ts # HR_IDENTIFIER_HMAC_PORT
│ ├── jurisdiction-provider.port.ts
│ ├── employee-directory.port.ts # the Phase B/D contract
│ └── errors/
├── application/ # use cases; depend only on domain
│ ├── jurisdiction-registry.service.ts
│ ├── identifier-validation.service.ts
│ ├── hr-capabilities.service.ts
│ ├── employee-directory.service.ts
│ └── import/
├── infrastructure/ # Kysely adapters, crypto, country providers
│ ├── crypto/
│ └── jurisdictions/{registry.ts, gt/, custom/}
└── interfaces/ # controllers, DTOs, query objects

EmployeeDirectoryPort is the only sanctioned way in from another module. Phase B binds to EMPLOYEE_DIRECTORY_PORT, not to a repository and not to a service class. Guarantees G1–G8 are in specs/056-hr-foundation/contracts/employee-directory.port.md.


The jurisdiction registry has two sources and one shape​

Every country-dependent field, option and validation rule comes from a JurisdictionDeclaration. There are two ways one gets built, and consumers cannot tell them apart — that is the point.

SourceWho writes itScopeTier
Code providerFlowPOS, in infrastructure/jurisdictions/<code>/All businessescertified
Merchant rowsThe administrator, in hr_jurisdiction_definitionOne businesscustom

Guatemala is the only certified country today (GT_DPI, GT_NIT, GT_IGSS). Everything else resolves to the custom provider, whose declaration is empty until a merchant fills it.

Two rules hold this together:

  • The core never branches on country code. No if (countryCode === "GT") outside infrastructure/jurisdictions/. Enforced by __tests__/no-country-branching.spec.ts, which has been seen to fail with the branch planted.
  • No user-authored regular expressions are evaluated, anywhere. A merchant's validation rule is a declarative grammar — character class, length, check digit — evaluated in O(length). A regex from a merchant is untrusted input in the path of every identifier save, and catastrophic backtracking is not reliably detectable. ValidationRule has no pattern member to put one in.

Providers are registered in one explicit map. No reflection, no directory scanning: "which countries are certified?" is answered by reading one file.


Permissions​

Three CASL resources, in packages/global/policies:

ResourceCoversGranted by default to
HrEmployeeThe staff register: people, org structure, contracts, statuses, suspensions (category only)administrator, store_manager, admin
HrCompensationPay, bank details, identifier plaintext, suspension notesno role bundle
HrJurisdictionMerchant-authored country rulesno role bundle

The last two follow the documented CashDrawer precedent: granted deliberately, per business, not by belonging to a role.

The suspension list is the one route whose permission is not uniform across its payload. The route is HrEmployee — that a colleague is suspended, and under which category, is roster information a supervisor needs. The free-text note inside it is HrCompensation. The controller resolves the caller's scope per request and picks one of two repository reads, so a caller without the scope never has the note in memory.

Two bypasses, one recorded as intended and one closed​

Owner — intended. A business owner holds All on All for their own business, which includes every HR resource. This is not an oversight and is not fixed here: it is their business's data. The compensating control is the access log (FR-048), which records an owner's read exactly like anyone else's. The data is not hidden from them; their access is not invisible either.

Platform staff — denied. root, platform admin, super and support also held All on All. Before HR that reached products and settings. HR changes what it reaches, so user.rules.ts now adds an explicit cannot on HrCompensation for those four roles. Platform staff can support HR — structure, contracts, statuses — and cannot read pay or identity data.

AppRoleName.Default is deliberately not in that list. It is the platform claim every merchant user carries, and the guard merges business rules and user rules into one CASL ability where a cannot beats a can from anywhere in the set. Denying default would revoke compensation from every merchant in the product.

Break-glass — a time-limited grant with a stated reason, every read logged against the grant and visible to the business owner — is deliberately not built. Denying is cheap now; designing break-glass before anyone has needed it would be guessing at its shape.

The deny was decoration until the evaluator was fixed​

Worth knowing, because the same trap is still available elsewhere in the codebase.

PolicyAction.All and PolicyResource.All are both the literal string "*". CASL does not treat that as a wildcard — its own are manage and all — so a grant of All on All is matched only by asking about the "*" subject, not about the resource the request is for. RolesGuard therefore emulated wildcards with a series of shortcut checks.

A cannot written against HrCompensation is not the "*" subject. The first shortcut matched root's grant, returned true, and the deny was never consulted. The rule existed, read correctly, and did nothing.

Worse, that evaluator existed twice — once in RolesGuard, once copied into HrCapabilitiesService under a comment saying it mirrored the guard. A deny honoured by one and ignored by the other is the worst arrangement available: the API refuses and the UI shows the compensation tab anyway, so the user meets a 403 in the one place the product told them to look.

Both now call packages/global/policies/src/utils/evaluate-access.ts, which checks for an explicit inverted rule before the wildcard shortcuts. If you add a cannot anywhere in this system, check it survives that function.

Module enablement is enforced, not just hidden​

Business modules used to decide what appeared in the sidebar and nothing else. A business with HR switched off saw no HR menu entries and could call every HR route by typing the URL.

@RequiresModule("hrModule") plus ModuleEnabledGuard now refuse those requests. RolesGuard runs first, so a caller without permission is refused for that reason before a module-specific 403 could hint at which businesses exist.

Two things to know:

  • hrModule is not a core module. No existing business has it enabled; it is switched on deliberately, per business. HR will not appear until it is.
  • This is scoped to HR. No other module enforces enablement at the route layer. Applying it product-wide would make every module-owned controller start refusing requests it used to serve, and nobody has established which businesses run a module switched off while still using its screens.

Confidentiality​

Three moving parts, each solving something the others cannot.

AEAD ciphertext. value_encrypted and account_encrypted are AES-GCM, through aes-gcm-encryption.adapter.ts. Not CryptoService, which is AES-256-CBC with no auth tag and whose decrypt() returns "" on failure — a corrupted bank account would be indistinguishable from a blank one. decrypt() here throws.

A keyed HMAC for identity. GCM ciphertext is non-deterministic, so a unique index on it is useless, and FR-020 requires duplicate detection without exposing stored values. value_hmac is HMAC-SHA256 over businessId : identifierType : normalizedValue, keyed by HR_IDENTIFIER_HMAC_KEY — which must be distinct from ENCRYPTION_KEY.

Three properties fall out, and each is load-bearing:

  • The same value in two businesses produces different digests, so one tenant cannot probe another's.
  • Normalization happens before hashing, from the identifier type's own NormalizationRule, so 12345678-9 and 123456789 are one person rather than two.
  • hmac_key_version and normalization_ver are stored per row, because a digest is only comparable within a version. A row written under version 1 is not comparable to a version 2 digest — not a non-match. Treating it as a non-match would silently create a duplicate person during a key rotation.

A stored last4. Lists and search results render from it, so the common path never touches ciphertext at all.

The access log records exposure​

sensitive_access_log is append-only: ISensitiveAccessLogRepository exposes no update and no delete, and a structural test asserts no such member can be added. The append happens before the sensitive payload is serialised, and a failed append fails the request — a read that could not be recorded does not happen.


Import​

One transaction. Preview runs the identical routine inside a transaction that always rolls back; commit runs it and does not. That is what makes "the preview predicts the commit" a property rather than an aspiration — a preview validating in memory could not see the identifier uniqueness index or the compensation exclusion constraint.

Rejections carry a code from a closed set, never a message. The report is downloadable as CSV (FR-066a), and a downloaded file leaves the product; whether a server-composed message contained a national ID would depend on how every domain, driver and future error phrases itself, which nobody reviewing the import path can see. RowError has no message field. Words are rendered client-side from hr.import.rejections.<CODE>.

A large import holds an identifier lock for its whole transaction​

Worth knowing before anyone raises the row limit or runs two imports at once.

Every identifier write takes pg_advisory_xact_lock over (business_id, identifier_type) — the lock that closes the version-transition window a unique index cannot (FR-020b1). It is transaction-scoped, and an import is one transaction. So the lock is taken on the first row carrying a DPI and held until the import commits or rolls back.

At 200 rows that is roughly a third of a second. At the 2,000-row limit it is measured in seconds, and for that whole time any other write of the same identifier type in the same business waits: a second import, an administrator saving a DPI on the employee screen, the identifier step of a hire. A Guatemalan import writes DPI, NIT and IGSS, so in practice it holds three such locks.

What it does not block: other identifier types, other businesses, and every non-identifier write — contracts, compensation, the directory. The lock is scoped to the type deliberately, so a big import does not stop the rest of HR.

Two consequences:

  • Raising IMPORT_MAX_ROWS lengthens that window proportionally. It is not only a memory and duration question.
  • Two concurrent imports into one business serialise on their first shared identifier type rather than running in parallel. They are still correct — that is what the lock is for — but the second one waits for the first rather than overlapping it.

The PWA wizard is separate from the generic import wizard and reuses its presentational parts. The generic one is built on the polled import_job API; HR's import is synchronous and atomic, with a suggestion-resolution step that blocks commit and has no analogue there. See specs/056-hr-foundation/research.md D19a.


Things that will bite you​

  • Regenerate types after schema changes: pnpm run generate:types.
  • Migrations on this branch are frozen — it has been pushed. Fix forward.
  • Money is numeric(20,4), never the legacy money_minor domain. pnpm lint enforces it.
  • Location is a filter, not a boundary (FR-046a). The global LocationAccessGuard enforces any request field named locationId and would silently convert the directory's location filter into a boundary, so HR list endpoints carry @SkipLocationAccess(). An integration test fails if it is ever re-applied.
  • Hidden UI is cosmetic. The compensation tab is absent rather than disabled when the caller lacks the scope, but that is presentation. Every endpoint carries its own guard; a client that ignored GET /hr/capabilities would simply get 403s.
  • Employee ≠ user. Transaction attribution uses employee.id. HR extends the existing employee table rather than duplicating it, and never writes its deprecated salary_amount / hourly_rate / commission_rate or free-text department / position columns. __tests__/no-legacy-pay-fields.spec.ts enforces that.

Tests worth knowing about​

Several gates here have been seen to fail with a planted defect, per CLAUDE.md. If you change what they guard, re-plant and re-check rather than trusting the green:

TestGuards
no-country-branching.spec.tsNo country literal outside jurisdictions/
mcp/__tests__/hr-exclusion.spec.tsNo HR data reachable through MCP
tenant-isolation.integration.spec.tsEvery repository method scoped by businessId
location-filter-not-boundary.integration.spec.ts@SkipLocationAccess() still applied
import-atomicity.integration.spec.tsNothing written on failure; preview writes nothing
identifier-write-lock.integration.spec.tsThe advisory lock closing the version-transition window
hmac-versioning.integration.spec.tsAn old-version digest is not comparable, not a non-match
platform-roles-denied-sensitive-hr.spec.tsPlatform deny, and that it does not reach merchants
hr-module-enforced.spec.tsEvery HR controller carries both the decorator and the guard
post-status-declared.spec.tsEvery route returns the status its @ApiResponse advertises