@happyvertical/smrt-human-resources
HR records for s-m-r-t: who works for an organization, as what, and with what standing.
- Employment: one record per tenant and person, with an employee number, worker type, position, status, dated terms and an append-only change history.
- Qualifications: a managed list of definitions (tickets, certifications, authorizations, training, restrictions) and what each person holds, with issue and expiry dates.
Pay belongs to payroll, time records to smrt-timesheets, logins and
permissions to smrt-users, and identity to smrt-profiles.
Install
pnpm add @happyvertical/smrt-human-resources
Access
Every model is tenant-scoped (required), sensitive, and publishes no generated
REST, MCP or CLI operation; the collection classes are closed the same way.
All writes go through EmploymentService and
QualificationService. The application authorizes the acting person first,
then constructs a service from that trusted actor:
const actor = { tenantId, profileId: managerProfileId };
profileId is a smrt-profiles Profile id and is recorded on every change.
Models reject save() and delete() outside the services (an application
subclass of Employment may save its own fields; see below).
Dates
Dates are calendar dates written YYYY-MM-DD, with no timezone. Convert an
instant to your local date before asking "on what day". An employment term's
endedOn is the last day employed, inclusive.
Employment
import { EmploymentService } from '@happyvertical/smrt-human-resources';
const hr = new EmploymentService(
db,
{ tenantId, profileId: managerProfileId },
{ onEvent: (event) => void notified.push(event.type) },
);
const employment = await hr.hire({
profileId: workerProfileId,
employeeNumber: 'E-1042',
startedOn: '2026-01-05',
position: 'Welder',
});
const check = await hr.check(workerProfileId, '2026-02-01');
// { ok: true, employmentId: employment.id, onLeave: false }
await hr.end(employment.id as string, {
endedOn: '2026-03-31', // last day employed, inclusive
reason: 'Season over',
});
const after = await hr.check(workerProfileId, '2026-04-01');
// { ok: false, reason: 'ended' }
const rehired = await hr.rehire(employment.id as string, {
startedOn: '2026-06-01',
});
// rehired.id === employment.id; hr.terms(rehired.id) has two terms
- An end may be recorded ahead of the last day (a notice period). The stored
status becomes
endedat once and means "an end has been recorded", not "no longer employed today": the person is employed up to and includingendedOn. Ask "on a date" withcheck(profileId, on),employedOn(on),asOf(employmentId, on)orfindByUser(userId, on). The package has no clock and never flips a status when a date passes. - There is one employment per tenant and profile. A rehire adds a term to the same record, so its id is stable for anything that references it.
check(profileId, on)andemployedOn(on)answer from the dated terms, not from the stored status. Someone on leave is still employed;checkreportsonLeave.changePosition,changeWorkerType,placeOnLeaveandreturnFromLeaveare dated changes;history()returns them andasOf(employmentId, on)replays them for a past date.linkLoginandunlinkLoginare recorded inhistory()with theireffectiveOn, but they are not replayed and not scheduled: the login is linked or cleared as soon as the call is made, whatever the date says, andfindByUser(userId, on)reads the login stored now. To unlink on a future day, callunlinkLoginon that day.- Position, worker-type and leave changes are accepted inside the latest
term. After an end is recorded that is the final term, up to and including
its
endedOn: the change is recorded and raises its usual event, the stored status staysended, and leave is validated against the replayed history (check().onLeaveandasOfanswer it). A date after the last day is rejected withHR_STATUS_TRANSITION(rehire first); a date before the term started withHR_INVALID. - A term holds its own changes:
endrejects anendedOnearlier than a position, worker-type or leave change already recorded in the term, andrehirerejects a start before one. - The login link (
userId) is optional, and a login belongs to one employment at a time. It stays with that employment, ended or not, untilunlinkLoginclears it there:hire,rehireandlinkLoginreject (HR_INVALID) a login stored on any other employment, so a worker on notice keeps their login and nobody else can take it. To move a login, unlink it first (unlinkLoginworks on an ended employment).findByUser(userId)returns only an employment whose stored status is notended, so it stops resolving as soon as an end is recorded.findByUser(userId, on)answers from the terms instead: the linked employment with a term coveringon, whatever its status. A host that records end dates ahead of time should pass the date, or a worker on notice is locked out.
Qualifications
import { QualificationService } from '@happyvertical/smrt-human-resources';
const qualifications = new QualificationService(db, actor, {
onEvent: (event) => void events.push(event), // after commit; a throwing handler never rolls back
});
const firstAidTicket = await qualifications.define({
key: 'first-aid',
name: 'First aid',
kind: 'certification',
expires: true,
validityMonths: 36,
});
const held = await qualifications.grant({
qualificationId: firstAidTicket.id as string,
profileId: workerProfileId,
issuedOn: '2026-03-01', // expiresOn defaults to 2029-03-01
certificateNumber: 'FA-20931',
verified: true,
});
const gate = await qualifications.check(
workerProfileId,
firstAidTicket.id as string,
'2029-03-02',
); // { ok: false, reason: 'expired' }
const renewal = await qualifications.renew(held.id as string, {
issuedOn: '2029-02-15',
}); // a new row; `held` still answers for 2026-03-01..2029-03-01
const after = await qualifications.check(
workerProfileId,
firstAidTicket.id as string,
'2029-03-02',
); // { ok: true, heldQualificationId: renewal.id, expiresOn: '2032-02-15' }
check()is the gate an application calls before allowing work. When it fails it says why:not-held,not-yet-issued,expired,suspendedorrevoked.- Expiry is computed from the dates, so a check needs no sweep.
- Standing is decided by date everywhere.
suspend,reinstateandrevoketake aneffectiveOnthat may be ahead; the storedstatuschanges at once and only says what was last recorded.check,holders,listForProfileandexpiringWithinall answer from the dated history, and so do the rules for the next change:suspendneeds the qualification to be neither suspended nor revoked oneffectiveOn,reinstateneeds it suspended on that day, and neither may be dated before the latest suspension or reinstatement. revokeis accepted for any date from the first issue date unless the qualification is already revoked on or before it. A revocation recorded for a later date (scheduled, or the end of an employment whose last day is ahead) freezes nothing: until it takes effect the qualification can still be suspended, reinstated, renewed, or revoked from an earlier day, and the earliest revocation is the one in force. A storedrevokedis final, so those later changes are history rows only and the status keeps readingrevoked.- A person has one live grant of a qualification at a time.
grantis refused (HR_ALREADY_HELD) unless everything they already hold of it is revoked effective on or before the newissuedOn: a revocation scheduled for 2027 does not allow a fresh grant dated 2026 (renew the existing one instead), and a lapsed ticket is renewed, not granted again. - A definition's
scopesays who a held qualification belongs to. Apersonqualification survives a change of employment. Anemploymentone is revoked, with reasonemployment-ended, when that employment ends: it is still good on the last day employed and revoked from the day after. That cutoff always wins: it also applies to a qualification whose own revocation was scheduled for a later date, and to one granted or renewed after the end was recorded (during notice, or backdated), which is storedrevokedat once and passescheckonly through the last day employed. The end is decided per renewal chain: the latest row gets one revocation and oneheld-qualification.revokedevent, the rows it renewed are cut off through the chain, and a chain already revoked on or before the cutoff gets nothing. - A renewal is a new row, and the chain shares its standing. Suspend, reinstate
or revoke the latest row: while it is suspended or revoked, the rows it
renewed do not pass
checkeither, and acting on a row that was already renewed is rejected (HR_INVALID). The change may take effect on any date from the chain's first issue date, so a ticket in force today can still be suspended or revoked after its renewal was recorded ahead of time: act on the renewal with today's date, and the renewal does not restore standing when its own issue date arrives. - A renewal cannot end before what it renews (
HR_INVALID): the renewed row keeps answering for its own period, so a shorter renewal would lapse whilecheckstill passed. To replace a qualification with a shorter one, revoke it from that day and grant a new one. - A renewal cannot be issued before the latest suspension or reinstatement
of the row it renews (
HR_INVALID), nor while that row is suspended on the issue date (HR_STATUS_TRANSITION: reinstate it first), nor when the row is revoked on or before the issue date (HR_INVALID; grant a new one dated on or after the revocation). A row whose revocation takes effect later is still in force and can be renewed, so a ticket that lapses during a notice period is not stuck: the renewal carries the cutoff forward (the pending revocation, or the day after the last day employed if that is earlier), is storedrevoked, and raisesheld-qualification.renewedand.revoked. verify(heldQualificationId, { documentAssetId })records the actor as having verified the document, now, and optionally attaches or replaces it. It changes no status and works whatever the standing, including on a row storedrevoked.holders(qualificationId, on, { employedOnly: true })lists, for example, the first aiders currently employed.expiringWithin(days, today)lists what is about to lapse: qualifications that passcheckontoday(issued, not suspended or revoked on that day, not already renewed) and whose last day is within the window;{ employedOnly: true }keeps only people employed ontoday.- A blank id is rejected with
HR_INVALID;checkanswersnot-held. seed(SUGGESTED_QUALIFICATIONS)creates a few common definitions; it never changes ones that already exist.- A qualification is not a permission. Who may use the software stays with
smrt-users; an application may require both.
Events and expiry
Both services take an onEvent hook, called after the change commits:
employment.hired, .ended, .rehired, .position-changed,
.worker-type-changed, .leave-started, .leave-ended, and
held-qualification.granted, .renewed, .suspended, .reinstated,
.revoked, .expired. Delivery is best-effort; bridge it to your own event
bus if you need more.
Construct the services with your root database handle. Each mutation begins
its own transaction (beginTransaction()), commits it, and then delivers its
events. A service built on a handle that is already inside a transaction (the
tx passed to db.transaction(), a savepoint scope, or a
beginTransaction() handle) could only deliver before the outer commit, so
it refuses every mutation with HR_TRANSACTION_UNSUPPORTED and writes
nothing; reads still work. Such a handle either has no beginTransaction or
refuses it with the SDK's NestedTransactionError (the native-capabilities
SQLite adapter, used for vector / notifications); the services never open
a nested transaction. An HR mutation therefore cannot be made atomic with
other work in a caller's transaction.
The package is tested on SQLite and PostgreSQL. Ids are normalized (to canonical lowercase UUIDs) only on PostgreSQL; on any other adapter, DuckDB included, pass canonical lowercase UUIDs yourself.
Nothing in the package runs on a schedule. Call
qualifications.sweepExpired(today) from your own scheduler to record lapsed
qualifications and receive held-qualification.expired. It decides by date,
so a ticket that runs out while suspended, or during a notice period, is
recorded too. A scheduled sweep should pass { since }, a day on or before
the last day its previous run covered, so it does not re-read all history.
Reminders are the application's business.
Extending Employment
Add application fields with a same-named subclass over the employments
table. Restate the closed surface on the subclass: exposure is not inherited.
Read your subclass through your own collection and save() your own fields on
a row the service created. The columns this package owns (tenantId,
profileId, userId, employerProfileId, employeeNumber, workerType,
position, status) still change only through EmploymentService: a save
that changes one of them, or that would insert a row, throws
HR_WRITE_FORBIDDEN.
Svelte components
@happyvertical/smrt-human-resources/svelte exports props-driven components:
an employee list, an employee form, a person's qualifications with their
expiry state, and an expiring-soon list. The host loads records through the
services and passes plain view objects.
EmployeeList shows the stored status. Pass the latest term's endedOn as
endsOn (the fourth argument of toEmployeeView) and the row also reads
"Last day 2026-12-31", so a worker on notice is not mistaken for one who has
left. The component never compares the date with today.
EmployeeForm never saves. For a new hire it returns startedOn; for an edit
it returns a required effectiveOn (prefilled from the today prop), which
the host passes to changePosition, changeWorkerType, linkLogin or
unlinkLogin for each value that changed. Position and worker-type changes
take effect on that date; a login link or unlink takes effect at once, so a
future-dated edit that also clears the login unlinks it immediately. A host
that wants the login kept until the date should hold the unlink back and
call unlinkLogin on that day.