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.
| Source | Who writes it | Scope | Tier |
|---|---|---|---|
| Code provider | FlowPOS, in infrastructure/jurisdictions/<code>/ | All businesses | certified |
| Merchant rows | The administrator, in hr_jurisdiction_definition | One business | custom |
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")outsideinfrastructure/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.
ValidationRulehas 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:
| Resource | Covers | Granted by default to |
|---|---|---|
HrEmployee | The staff register: people, org structure, contracts, statuses, suspensions (category only) | administrator, store_manager, admin |
HrCompensation | Pay, bank details, identifier plaintext, suspension notes | no role bundle |
HrJurisdiction | Merchant-authored country rules | no 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:
hrModuleis 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, so12345678-9and123456789are one person rather than two. hmac_key_versionandnormalization_verare 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_ROWSlengthens 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 legacymoney_minordomain.pnpm lintenforces it. - Location is a filter, not a boundary (FR-046a). The global
LocationAccessGuardenforces any request field namedlocationIdand 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/capabilitieswould simply get 403s. - Employee ≠ user. Transaction attribution uses
employee.id. HR extends the existingemployeetable rather than duplicating it, and never writes its deprecatedsalary_amount/hourly_rate/commission_rateor free-textdepartment/positioncolumns.__tests__/no-legacy-pay-fields.spec.tsenforces 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:
| Test | Guards |
|---|---|
no-country-branching.spec.ts | No country literal outside jurisdictions/ |
mcp/__tests__/hr-exclusion.spec.ts | No HR data reachable through MCP |
tenant-isolation.integration.spec.ts | Every repository method scoped by businessId |
location-filter-not-boundary.integration.spec.ts | @SkipLocationAccess() still applied |
import-atomicity.integration.spec.ts | Nothing written on failure; preview writes nothing |
identifier-write-lock.integration.spec.ts | The advisory lock closing the version-transition window |
hmac-versioning.integration.spec.ts | An old-version digest is not comparable, not a non-match |
platform-roles-denied-sensitive-hr.spec.ts | Platform deny, and that it does not reach merchants |
hr-module-enforced.spec.ts | Every HR controller carries both the decorator and the guard |
post-status-declared.spec.ts | Every route returns the status its @ApiResponse advertises |