@happyvertical/smrt-human-resources 0.55.7 → 0.55.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +10 -1
- package/dist/smrt-knowledge.json +13 -4
- package/package.json +8 -8
package/dist/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"version": "1.0.0",
|
|
3
3
|
"timestamp": 0,
|
|
4
4
|
"packageName": "@happyvertical/smrt-human-resources",
|
|
5
|
-
"packageVersion": "0.55.
|
|
5
|
+
"packageVersion": "0.55.8",
|
|
6
6
|
"objects": {
|
|
7
7
|
"@happyvertical/smrt-human-resources:Employment": {
|
|
8
8
|
"name": "employment",
|
|
@@ -2475,5 +2475,14 @@
|
|
|
2475
2475
|
"@happyvertical/smrt-core",
|
|
2476
2476
|
"@happyvertical/smrt-tenancy"
|
|
2477
2477
|
],
|
|
2478
|
+
"browser": {
|
|
2479
|
+
"status": "server-only",
|
|
2480
|
+
"issues": [
|
|
2481
|
+
"#3624"
|
|
2482
|
+
],
|
|
2483
|
+
"via": [
|
|
2484
|
+
"@happyvertical/smrt-tenancy"
|
|
2485
|
+
]
|
|
2486
|
+
},
|
|
2478
2487
|
"stampsConstructors": true
|
|
2479
2488
|
}
|
package/dist/smrt-knowledge.json
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
"sensitiveFieldsExcluded": true,
|
|
4
4
|
"generatedAt": "1970-01-01T00:00:00.000Z",
|
|
5
5
|
"packageName": "@happyvertical/smrt-human-resources",
|
|
6
|
-
"packageVersion": "0.55.
|
|
6
|
+
"packageVersion": "0.55.8",
|
|
7
7
|
"sourceManifestPath": "dist/manifest.json",
|
|
8
8
|
"agentDocPath": "AGENTS.md",
|
|
9
9
|
"sourceHashes": {
|
|
10
|
-
"manifest": "
|
|
11
|
-
"packageJson": "
|
|
10
|
+
"manifest": "fc8ba6fa556830c4590ef903e629221e8631996666caf09d454640d21efe15e2",
|
|
11
|
+
"packageJson": "de986ac1788427bed2fc7479779de37f342ba392cca73354a56f5c7f3e6fd5ca",
|
|
12
12
|
"agents": "441cce6da32d348df8bb6300d526f118d70efa5680c80615d373cc8ef98e95f4"
|
|
13
13
|
},
|
|
14
14
|
"exports": [
|
|
@@ -911,5 +911,14 @@
|
|
|
911
911
|
"polymorphicAssociations": 0,
|
|
912
912
|
"uuidColumns": 52
|
|
913
913
|
},
|
|
914
|
-
"agentDoc": "# smrt-human-resources\n\nHR records: who works for an organization, as what, and with what standing\n(#3435). It sits between identity (`smrt-profiles`) and pay (payroll, #3292):\npayroll → human-resources → profiles. It holds no pay, no time records, no\nlogins or permissions, and no identity or contact details.\n\n## Models\n\n| Model | Table | Notes |\n|---|---|---|\n| `Employment` | `employments` | One per tenant and profile. `profileId`, optional `userId` and `employerProfileId`, `employeeNumber` (unique per tenant), `workerType`, `position`, `status` (`active` \\| `on-leave` \\| `ended`) |\n| `EmploymentTerm` | `employment_terms` | One continuous period: `startedOn`, `endedOn` (last day, inclusive; null is open), `endReason`. A rehire adds a term |\n| `EmploymentChange` | `employment_changes` | Append-only dated change: `kind`, `effectiveOn`, `fromValue`, `toValue`, `actorProfileId` |\n| `Qualification` | `qualifications` | Definition, `key` unique per tenant: `kind`, `issuingBody`, `expires`, `validityMonths`, `scope` (`person` \\| `employment`) |\n| `HeldQualification` | `held_qualifications` | A person holds a qualification: `issuedOn`, `expiresOn`, `certificateNumber`, `status`, verifier, optional `documentAssetId`, `renewalOfId` |\n| `HeldQualificationChange` | `held_qualification_changes` | Append-only dated change: `kind`, `effectiveOn`, `reason`, `actorProfileId` |\n\n`EmploymentService` and `QualificationService` are the only writers (an\napplication subclass may save its own added fields; see \"Extending\nEmployment\"). Both are\nconstructed from a trusted `{ tenantId, profileId }` actor the application has\nalready authorized, and both take an optional `onEvent` hook. Construct them\nwith the root database handle, never one that is already inside a transaction\n(see \"Events need the root handle\").\n\n## Invariants\n\n- **Sensitive and closed.** Every model is `@TenantScoped({ mode: 'required' })`,\n `sensitive: true`, with `api` / `mcp` / `cli` set to `{ include: [] }`.\n `save()` throws outside a service (`src/write.ts`) and `delete()` always\n throws. Services insert rows only through `insertHr()` in `src/write.ts`,\n which uses the model's public constructor, `initialize()` and\n `requireInsertOnSave()`; never reach into a collection's protected\n `createUnsaved()`. A subclass must restate the closed surface: `@smrt` exposure is not\n inherited, and an empty `@smrt()` exposes everything.\n- **Collections are closed too.** Collection classes are registered like\n models, so each of the six carries\n `@smrt({ api: { include: [] }, mcp: { include: [] }, cli: false })` as an\n inline literal (the scanner cannot resolve an imported constant). An\n undecorated collection publishes its methods as MCP tools. A collection's\n config merges over its model's, so a model's effective `cli` reads `false`.\n `src/__tests__/closed-surface.test.ts` asserts no generated tool exists.\n- **The tenant is the employer.** `employerProfileId` is informational and\n never part of uniqueness. There is one `Employment` per tenant and profile,\n so (tenant, profile, date) resolves to at most one employment.\n- **The employment id is stable.** Rehire adds a term to the same row; other\n packages and applications key on `Employment.id`.\n- **Dates are calendar dates.** `YYYY-MM-DD` text, compared as strings, with\n no timezone. The application converts an instant to its local date first.\n `verifiedAt` is the one instant.\n- **\"Employed on a date\" comes from terms**, never from `status`. `on-leave`\n still counts as employed.\n- **`ended` means an end has been recorded**, not \"no longer employed today\".\n `end` accepts an `endedOn` ahead of the day it is called (a notice period)\n and stores `ended` at once; the package has no clock and never flips a\n status when a date passes. For \"on a date\" use `check`, `employedOn`, `asOf`\n or `findByUser(userId, on)`.\n- **Dated changes belong to the latest term.** `changePosition`,\n `changeWorkerType`, `placeOnLeave` and `returnFromLeave` are accepted when\n `effectiveOn` is inside the latest term: the open one, or after an end is\n recorded the final one up to and including its `endedOn`. On an ended\n employment the stored status stays `ended`, the row's `position` /\n `workerType` update as usual, leave is validated from the replayed history\n as of `effectiveOn` (not from `status`), and the usual event is raised. A\n date after the last day is `HR_STATUS_TRANSITION`; before the term started,\n `HR_INVALID`.\n- **History is append-only.** Changes are new rows. A term is closed once. A\n renewal is a new `HeldQualification` pointing at the old one through\n `renewalOfId`. A row stored `revoked` is never saved again, except for its\n verification fields (`HeldQualification.save()` allows a save that changes\n only `verifiedByProfileId`, `verifiedAt` and `documentAssetId`); a later\n suspension, reinstatement or earlier revocation of one is a further change\n row, never an edit.\n- **A stored qualification status is \"the last change recorded\", not\n standing, and no rule decides standing from it.** `revoke` and `suspend`\n store their status at once even when `effectiveOn` is ahead, and `reinstate` stores `valid`\n even when it takes effect later (`expired` if the sweep has recorded the\n row's lapse).\n The one sticky value is `revoked`: once stored it stays, whatever is\n recorded afterwards. Every rule decides from the dated history, as `check`\n does: the one-live-grant rule, renewal validation, `expiringWithin`, the\n employment-end cutoff, and the transitions themselves.\n- **Transitions are validated by replay as of `effectiveOn`.** On the latest\n row of a chain, from the chain's whole history: `suspend` needs the chain\n neither suspended nor revoked on `effectiveOn`; `reinstate` needs it\n suspended (and not revoked) on `effectiveOn`; both are `HR_INVALID` when\n dated before the chain's latest suspension or reinstatement, so those two\n are recorded in date order. `revoke` is exempt from that ordering (it is\n terminal and the earliest one wins in replay) and is refused only when a\n revocation effective on or before `effectiveOn` is already recorded\n (`HR_STATUS_TRANSITION`). So a revocation recorded for a later date (a\n scheduled one, an employment end with a last day ahead, a grant against a\n closed term) freezes nothing: until it takes effect the qualification can\n be suspended, reinstated, renewed, or revoked from an earlier day. A stored\n `expired` blocks none of them.\n- **A term holds its own changes.** `end` rejects an `endedOn` earlier than a\n position, worker-type or leave change recorded in the open term, and\n `rehire` a start before one, so replay (`check().onLeave`, `asOf`) never\n picks up a change dated outside its term. Login links are not replayed and\n are not part of this rule.\n- **Login links are immediate.** `linkLogin` / `unlinkLogin` write the stored\n `userId` at once; `effectiveOn` is only recorded on the change row. `asOf`\n does not replay them and `findByUser(userId, on)` reads the stored `userId`\n (only the terms are dated).\n- **A login is exclusive to the employment that stores it.** `hire`, `rehire`\n and `linkLogin` reject (`HR_INVALID`) a `userId` stored on any other\n employment in the tenant, whatever that employment's status: an ended\n employment keeps its login through its notice period and after, until\n `unlinkLogin` (which works on an ended employment) clears it. So two\n employments never share a login through the service, and neither lookup\n can be ambiguous; `findByUser` still fails closed on an ambiguous match as\n a backstop for rows written some other way. `findByUser(userId)` goes by\n the stored status; `findByUser(userId, on)` goes by terms covering `on`,\n whatever the status, and is what a host that records end dates ahead of\n time must call.\n- **A renewal chain shares its standing.** `check`, `holders` and\n `listForProfile` treat a row as not good on a date when a later row in its\n chain is suspended or revoked on that date. `suspend`, `reinstate` and\n `revoke` act on the latest row only (`HR_INVALID` on a row that was\n renewed), and the change belongs to the chain: `effectiveOn` may be any\n date from the chain's first `issuedOn` onward (see \"Transitions are\n validated by replay\" for the ordering rule). It may precede the latest\n row's own `issuedOn`. That is how a ticket in force today is withdrawn\n after its renewal was recorded ahead of time: the earlier row stops passing from\n `effectiveOn`, and the renewal does not restore standing when its issue\n date arrives (revoked from issue, or suspended from issue until\n reinstated). `listForProfile` reports the status of the latest row issued\n by the date, so it agrees with `check` instead of answering\n `not-yet-issued`.\n- **A renewal is validated by date.** `renew` rejects an `issuedOn` earlier\n than the row's own, or earlier than its latest suspension or reinstatement\n (`HR_INVALID`: the renewal would answer for dates the history says were\n suspended); a row suspended on `issuedOn` by replay\n (`HR_STATUS_TRANSITION`: reinstate first); a row revoked on or before\n `issuedOn` (`HR_INVALID`); an expiry earlier than the row's own, or any\n expiry on a renewal of a row that has none (`HR_INVALID`: the renewed row\n keeps answering for its own period, so a shorter renewal would lapse while\n `check` still passed; to replace a qualification with a shorter one,\n revoke it from that day and grant a new one); and a person who holds\n another chain that is not\n revoked on or before `issuedOn` and overlaps the renewal\n (`HR_ALREADY_HELD`). A chain that only starts on or after the day the\n renewal is cut off (its carried revocation) does not overlap, so a lapsed\n ticket can be renewed up to its scheduled revocation even when the next\n grant, dated from that day, was recorded first.\n- **A renewal carries a pending cutoff forward.** A row whose revocation\n takes effect after `issuedOn` is still in force and is renewed normally;\n replay only propagates standing from later rows to earlier ones, so the\n renewal records the cutoff on itself: a `revoked` change at the earlier of\n the old row's pending revocation (with its reason) and, inside a closed\n employment term, `endedOn + 1` (`employment-ended`; it wins a tie). The\n renewal is stored `revoked` and raises `renewed` then `revoked`. Each\n remaining latest row therefore holds its chain's revocation, which\n `expiringWithin`, the one-live-grant rule and `renew` rely on.\n- **One live grant, by date.** `grant` is refused (`HR_ALREADY_HELD`) unless\n every chain the person already holds of that qualification is revoked\n effective on or before the new `issuedOn`. A revocation recorded for a\n later date does not free an earlier issue date (the message says to renew\n the existing one), and a lapsed ticket, swept or not, is renewed instead of\n granted again.\n- **Verification is not a status.** `verify` sets `verifiedByProfileId` and\n `verifiedAt` (and optionally the document) on any row, including one stored\n `revoked`; it writes no change row and raises no event.\n- **Ids are validated.** A blank or missing id is `HR_INVALID` in both\n services (`HrService.id`); the `check` methods answer `not-employed` /\n `not-held` instead.\n- **Qualification scope.** A `person`-scoped held qualification survives a\n change of employment. An `employment`-scoped one carries `employmentId` and\n is revoked, with reason `employment-ended`, in the transaction that ends the\n employment. The revocation is effective the day after `endedOn`, so the\n qualification is still good on the last day employed. The cutoff is the\n earliest one, decided by date and per renewal chain\n (`revokeEmploymentQualifications`): the latest row of each chain of the\n employment gets it, with one `held-qualification.revoked` event, unless a\n revocation effective on or before it is already recorded anywhere in the\n chain. Rows that were renewed are not written; replay cuts them off through\n the chain. A latest row already stored `revoked` for a later date gets the\n change row only and the event carries the earlier date; replay takes the\n earliest revocation. A grant or renewal dated inside a term whose end is\n already recorded (granted during notice, or backdated) gets the same\n revocation in its own transaction and is stored `revoked` from the start.\n- **Expiry is computed.** A check on a date reads `expiresOn`; the stored\n `expired` status is written by `QualificationService.sweepExpired()`,\n which the application calls from its own scheduler (and by reinstating a\n row whose lapse the sweep has recorded); it is bookkeeping and\n no rule reads standing from it. The sweep decides which rows lapsed by\n date: every row whose last day is before `today`, that has no `expired`\n change yet and was not revoked on or before the day after `expiresOn`,\n gets an `expired` change and (unless it was renewed) the event. That\n includes a ticket that runs out while suspended, or while a revocation is\n recorded for a later day (a notice period). Only a row stored `valid` has\n its stored status set to `expired`; a stored `suspended` or `revoked`\n stays. Those rows are re-read on every run, so a scheduled sweep should\n pass `{ since }` (a day on or before the last day its previous run\n covered) to bound the work. `expiringWithin(days, today,\n { employedOnly })` lists rows that are good on `today` by the same dated\n evaluation as `check` (issued by `today`, not suspended or revoked on it,\n not renewed) and can keep only people employed on `today`.\n- **Events need the root handle.** A mutation begins its own transaction\n with `db.beginTransaction()`, commits it, then delivers its events.\n `@happyvertical/sql` has no after-commit hook and no marker for \"inside a\n transaction\" (happyvertical/sdk#1249), so on a handle that is already\n inside one (the `tx` of `db.transaction()`, a savepoint scope, or a\n `beginTransaction()` handle) the commit would be the caller's and an outer\n rollback could follow a delivered event. `HrService.transact` therefore\n refuses a mutation on such a handle with `HR_TRANSACTION_UNSUPPORTED`\n before writing anything. It recognises one by asking it to begin: a\n transaction-scoped handle either has no `beginTransaction` (libsql SQLite,\n PostgreSQL, DuckDB, JSON) or exposes one that throws\n `NestedTransactionError` without touching the connection (the\n native-capabilities SQLite adapter, opened for `vector` /\n `notifications`). Never go back to `db.transaction()` /\n `withEmbeddedWriteTransaction` here: on a transaction-scoped handle those\n re-enter the caller's transaction under a savepoint, or with\n `reuseUnsupportedNested` run on the caller's handle, and both fail open.\n Reads work on any handle. Tests that isolate with\n `createIsolatedTestDb*()` must hand the services `baseDb`, not the\n transaction handle.\n- **Tested on SQLite and PostgreSQL.** `HrService.identity` normalizes ids\n only on PostgreSQL. On any other adapter (DuckDB included) nothing is\n normalized, so callers must pass canonical lowercase UUIDs.\n- **Not a permission system.** A qualification says what a person may do in\n the real world. Who may use the software stays with `smrt-users`.\n- **People are `smrt-profiles:Profile` ids** in fields named `profileId` or\n `<role>ProfileId`, as cross-package refs. This is the id `smrt-timesheets`\n holds.\n\n## Extending Employment\n\nDeclare a same-named subclass over the `employments` table in the application,\nrestating the fields and the closed surface, and add application fields there\n(teamworks-os keeps succession rank and acting-role fields this way). Only one\ninheritance family may share a table; the deepest same-named subclass wins.\n\n`Employment.save()` lets such a subclass write its own fields: without the\nservice's write capability a save is allowed only for an existing row whose\npackage-owned columns (`tenantId`, `profileId`, `userId`, `employerProfileId`,\n`employeeNumber`, `workerType`, `position`, `status`) all still hold their\nstored values. A save that changes one of them, and any insert, throws\n`HR_WRITE_FORBIDDEN`; `delete()` always throws. Add a column to\n`PACKAGE_OWNED_COLUMNS` in `src/employment/models.ts` when the package gains\none.\n\n## Svelte UI\n\n`@happyvertical/smrt-human-resources/svelte` ships four components, each\nregistered as a slot in `HUMAN_RESOURCES_UI_SLOTS` (`src/ui.ts`):\n\n| Component | Slot | Shows |\n|---|---|---|\n| `EmployeeList` | `employee-list` | Name, employee number, position, worker type, status (plus \"Last day {date}\" when `endsOn` is set), start date |\n| `EmployeeForm` | `employee-form` | Employee number, worker type, position, start date (new hire only), effective date (edit only), optional login |\n| `PersonQualifications` | `person-qualifications` | One person's qualifications with their expiry state |\n| `ExpiringQualificationsList` | `expiring-qualifications-list` | Qualifications expiring soon across people, soonest first |\n\n- **Props-driven.** Components never load or save. The host calls the\n services, adapts rows with the helpers in `src/svelte/types.ts`\n (`toEmployeeView`, `toPersonQualificationView`,\n `toExpiringQualificationView`) and handles `onselect` / `onsubmit`.\n- **No clock in components.** `EmployeeList` shows the stored status badge\n and, when the host passes `endsOn` (the latest term's `endedOn`, fourth\n argument of `toEmployeeView`), the text \"Last day {date}\". It never compares\n a date with today to decide a label.\n- **Names come from the host.** HR holds no identity data, so every view\n carries a `displayName` the host supplies.\n- **`EmployeeForm` is hand-built** from smrt-ui form primitives (owner\n decision); the package does not depend on `smrt-fields` or `smrt-users`.\n- **`EmployeeForm` values are not a service input.** A hire returns\n `startedOn`; an edit returns a required `effectiveOn` (prefilled from\n `today`) that the host passes to `changePosition` / `changeWorkerType` /\n `linkLogin` / `unlinkLogin` for each value that changed. A login link or\n unlink takes effect at once whatever the date, so a future-dated edit that\n also clears the login unlinks it immediately; a host that wants it kept\n until then calls `unlinkLogin` on that day. The employee's\n current login stays an option even when the host's `logins` omit it, so a\n save never unlinks it by accident. Each validation message is tied to its\n field with `aria-describedby`.\n- **Day arithmetic is UTC** (`daysUntil`); never build a local-time `Date`\n from a calendar date.\n- **Expiry state is text plus a badge**, never colour alone. \"Expiring soon\"\n is a valid qualification whose last day is within `expiringSoonDays`\n (default 30), inclusive.\n- **Strings** go through `useI18n()` and the catalog in `src/svelte/i18n.ts`,\n keyed `human_resources.<component>.<key>`. Only smrt-ui primitives; no raw\n `<input>` / `<button>` / `<select>`.\n\n## Validation commands\n\n```bash\npnpm --filter @happyvertical/smrt-human-resources build\npnpm --filter @happyvertical/smrt-human-resources test\npnpm --filter @happyvertical/smrt-human-resources typecheck\n```\n\nBuild before `test`: `src/__tests__/employment-extension.test.ts` reads\n`dist/manifest.json`.\n"
|
|
914
|
+
"agentDoc": "# smrt-human-resources\n\nHR records: who works for an organization, as what, and with what standing\n(#3435). It sits between identity (`smrt-profiles`) and pay (payroll, #3292):\npayroll → human-resources → profiles. It holds no pay, no time records, no\nlogins or permissions, and no identity or contact details.\n\n## Models\n\n| Model | Table | Notes |\n|---|---|---|\n| `Employment` | `employments` | One per tenant and profile. `profileId`, optional `userId` and `employerProfileId`, `employeeNumber` (unique per tenant), `workerType`, `position`, `status` (`active` \\| `on-leave` \\| `ended`) |\n| `EmploymentTerm` | `employment_terms` | One continuous period: `startedOn`, `endedOn` (last day, inclusive; null is open), `endReason`. A rehire adds a term |\n| `EmploymentChange` | `employment_changes` | Append-only dated change: `kind`, `effectiveOn`, `fromValue`, `toValue`, `actorProfileId` |\n| `Qualification` | `qualifications` | Definition, `key` unique per tenant: `kind`, `issuingBody`, `expires`, `validityMonths`, `scope` (`person` \\| `employment`) |\n| `HeldQualification` | `held_qualifications` | A person holds a qualification: `issuedOn`, `expiresOn`, `certificateNumber`, `status`, verifier, optional `documentAssetId`, `renewalOfId` |\n| `HeldQualificationChange` | `held_qualification_changes` | Append-only dated change: `kind`, `effectiveOn`, `reason`, `actorProfileId` |\n\n`EmploymentService` and `QualificationService` are the only writers (an\napplication subclass may save its own added fields; see \"Extending\nEmployment\"). Both are\nconstructed from a trusted `{ tenantId, profileId }` actor the application has\nalready authorized, and both take an optional `onEvent` hook. Construct them\nwith the root database handle, never one that is already inside a transaction\n(see \"Events need the root handle\").\n\n## Invariants\n\n- **Sensitive and closed.** Every model is `@TenantScoped({ mode: 'required' })`,\n `sensitive: true`, with `api` / `mcp` / `cli` set to `{ include: [] }`.\n `save()` throws outside a service (`src/write.ts`) and `delete()` always\n throws. Services insert rows only through `insertHr()` in `src/write.ts`,\n which uses the model's public constructor, `initialize()` and\n `requireInsertOnSave()`; never reach into a collection's protected\n `createUnsaved()`. A subclass must restate the closed surface: `@smrt` exposure is not\n inherited, and an empty `@smrt()` exposes everything.\n- **Collections are closed too.** Collection classes are registered like\n models, so each of the six carries\n `@smrt({ api: { include: [] }, mcp: { include: [] }, cli: false })` as an\n inline literal (the scanner cannot resolve an imported constant). An\n undecorated collection publishes its methods as MCP tools. A collection's\n config merges over its model's, so a model's effective `cli` reads `false`.\n `src/__tests__/closed-surface.test.ts` asserts no generated tool exists.\n- **The tenant is the employer.** `employerProfileId` is informational and\n never part of uniqueness. There is one `Employment` per tenant and profile,\n so (tenant, profile, date) resolves to at most one employment.\n- **The employment id is stable.** Rehire adds a term to the same row; other\n packages and applications key on `Employment.id`.\n- **Dates are calendar dates.** `YYYY-MM-DD` text, compared as strings, with\n no timezone. The application converts an instant to its local date first.\n `verifiedAt` is the one instant.\n- **\"Employed on a date\" comes from terms**, never from `status`. `on-leave`\n still counts as employed.\n- **`ended` means an end has been recorded**, not \"no longer employed today\".\n `end` accepts an `endedOn` ahead of the day it is called (a notice period)\n and stores `ended` at once; the package has no clock and never flips a\n status when a date passes. For \"on a date\" use `check`, `employedOn`, `asOf`\n or `findByUser(userId, on)`.\n- **Dated changes belong to the latest term.** `changePosition`,\n `changeWorkerType`, `placeOnLeave` and `returnFromLeave` are accepted when\n `effectiveOn` is inside the latest term: the open one, or after an end is\n recorded the final one up to and including its `endedOn`. On an ended\n employment the stored status stays `ended`, the row's `position` /\n `workerType` update as usual, leave is validated from the replayed history\n as of `effectiveOn` (not from `status`), and the usual event is raised. A\n date after the last day is `HR_STATUS_TRANSITION`; before the term started,\n `HR_INVALID`.\n- **History is append-only.** Changes are new rows. A term is closed once. A\n renewal is a new `HeldQualification` pointing at the old one through\n `renewalOfId`. A row stored `revoked` is never saved again, except for its\n verification fields (`HeldQualification.save()` allows a save that changes\n only `verifiedByProfileId`, `verifiedAt` and `documentAssetId`); a later\n suspension, reinstatement or earlier revocation of one is a further change\n row, never an edit.\n- **A stored qualification status is \"the last change recorded\", not\n standing, and no rule decides standing from it.** `revoke` and `suspend`\n store their status at once even when `effectiveOn` is ahead, and `reinstate` stores `valid`\n even when it takes effect later (`expired` if the sweep has recorded the\n row's lapse).\n The one sticky value is `revoked`: once stored it stays, whatever is\n recorded afterwards. Every rule decides from the dated history, as `check`\n does: the one-live-grant rule, renewal validation, `expiringWithin`, the\n employment-end cutoff, and the transitions themselves.\n- **Transitions are validated by replay as of `effectiveOn`.** On the latest\n row of a chain, from the chain's whole history: `suspend` needs the chain\n neither suspended nor revoked on `effectiveOn`; `reinstate` needs it\n suspended (and not revoked) on `effectiveOn`; both are `HR_INVALID` when\n dated before the chain's latest suspension or reinstatement, so those two\n are recorded in date order. `revoke` is exempt from that ordering (it is\n terminal and the earliest one wins in replay) and is refused only when a\n revocation effective on or before `effectiveOn` is already recorded\n (`HR_STATUS_TRANSITION`). So a revocation recorded for a later date (a\n scheduled one, an employment end with a last day ahead, a grant against a\n closed term) freezes nothing: until it takes effect the qualification can\n be suspended, reinstated, renewed, or revoked from an earlier day. A stored\n `expired` blocks none of them.\n- **A term holds its own changes.** `end` rejects an `endedOn` earlier than a\n position, worker-type or leave change recorded in the open term, and\n `rehire` a start before one, so replay (`check().onLeave`, `asOf`) never\n picks up a change dated outside its term. Login links are not replayed and\n are not part of this rule.\n- **Login links are immediate.** `linkLogin` / `unlinkLogin` write the stored\n `userId` at once; `effectiveOn` is only recorded on the change row. `asOf`\n does not replay them and `findByUser(userId, on)` reads the stored `userId`\n (only the terms are dated).\n- **A login is exclusive to the employment that stores it.** `hire`, `rehire`\n and `linkLogin` reject (`HR_INVALID`) a `userId` stored on any other\n employment in the tenant, whatever that employment's status: an ended\n employment keeps its login through its notice period and after, until\n `unlinkLogin` (which works on an ended employment) clears it. So two\n employments never share a login through the service, and neither lookup\n can be ambiguous; `findByUser` still fails closed on an ambiguous match as\n a backstop for rows written some other way. `findByUser(userId)` goes by\n the stored status; `findByUser(userId, on)` goes by terms covering `on`,\n whatever the status, and is what a host that records end dates ahead of\n time must call.\n- **A renewal chain shares its standing.** `check`, `holders` and\n `listForProfile` treat a row as not good on a date when a later row in its\n chain is suspended or revoked on that date. `suspend`, `reinstate` and\n `revoke` act on the latest row only (`HR_INVALID` on a row that was\n renewed), and the change belongs to the chain: `effectiveOn` may be any\n date from the chain's first `issuedOn` onward (see \"Transitions are\n validated by replay\" for the ordering rule). It may precede the latest\n row's own `issuedOn`. That is how a ticket in force today is withdrawn\n after its renewal was recorded ahead of time: the earlier row stops passing from\n `effectiveOn`, and the renewal does not restore standing when its issue\n date arrives (revoked from issue, or suspended from issue until\n reinstated). `listForProfile` reports the status of the latest row issued\n by the date, so it agrees with `check` instead of answering\n `not-yet-issued`.\n- **A renewal is validated by date.** `renew` rejects an `issuedOn` earlier\n than the row's own, or earlier than its latest suspension or reinstatement\n (`HR_INVALID`: the renewal would answer for dates the history says were\n suspended); a row suspended on `issuedOn` by replay\n (`HR_STATUS_TRANSITION`: reinstate first); a row revoked on or before\n `issuedOn` (`HR_INVALID`); an expiry earlier than the row's own, or any\n expiry on a renewal of a row that has none (`HR_INVALID`: the renewed row\n keeps answering for its own period, so a shorter renewal would lapse while\n `check` still passed; to replace a qualification with a shorter one,\n revoke it from that day and grant a new one); and a person who holds\n another chain that is not\n revoked on or before `issuedOn` and overlaps the renewal\n (`HR_ALREADY_HELD`). A chain that only starts on or after the day the\n renewal is cut off (its carried revocation) does not overlap, so a lapsed\n ticket can be renewed up to its scheduled revocation even when the next\n grant, dated from that day, was recorded first.\n- **A renewal carries a pending cutoff forward.** A row whose revocation\n takes effect after `issuedOn` is still in force and is renewed normally;\n replay only propagates standing from later rows to earlier ones, so the\n renewal records the cutoff on itself: a `revoked` change at the earlier of\n the old row's pending revocation (with its reason) and, inside a closed\n employment term, `endedOn + 1` (`employment-ended`; it wins a tie). The\n renewal is stored `revoked` and raises `renewed` then `revoked`. Each\n remaining latest row therefore holds its chain's revocation, which\n `expiringWithin`, the one-live-grant rule and `renew` rely on.\n- **One live grant, by date.** `grant` is refused (`HR_ALREADY_HELD`) unless\n every chain the person already holds of that qualification is revoked\n effective on or before the new `issuedOn`. A revocation recorded for a\n later date does not free an earlier issue date (the message says to renew\n the existing one), and a lapsed ticket, swept or not, is renewed instead of\n granted again.\n- **Verification is not a status.** `verify` sets `verifiedByProfileId` and\n `verifiedAt` (and optionally the document) on any row, including one stored\n `revoked`; it writes no change row and raises no event.\n- **Ids are validated.** A blank or missing id is `HR_INVALID` in both\n services (`HrService.id`); the `check` methods answer `not-employed` /\n `not-held` instead.\n- **Qualification scope.** A `person`-scoped held qualification survives a\n change of employment. An `employment`-scoped one carries `employmentId` and\n is revoked, with reason `employment-ended`, in the transaction that ends the\n employment. The revocation is effective the day after `endedOn`, so the\n qualification is still good on the last day employed. The cutoff is the\n earliest one, decided by date and per renewal chain\n (`revokeEmploymentQualifications`): the latest row of each chain of the\n employment gets it, with one `held-qualification.revoked` event, unless a\n revocation effective on or before it is already recorded anywhere in the\n chain. Rows that were renewed are not written; replay cuts them off through\n the chain. A latest row already stored `revoked` for a later date gets the\n change row only and the event carries the earlier date; replay takes the\n earliest revocation. A grant or renewal dated inside a term whose end is\n already recorded (granted during notice, or backdated) gets the same\n revocation in its own transaction and is stored `revoked` from the start.\n- **Expiry is computed.** A check on a date reads `expiresOn`; the stored\n `expired` status is written by `QualificationService.sweepExpired()`,\n which the application calls from its own scheduler (and by reinstating a\n row whose lapse the sweep has recorded); it is bookkeeping and\n no rule reads standing from it. The sweep decides which rows lapsed by\n date: every row whose last day is before `today`, that has no `expired`\n change yet and was not revoked on or before the day after `expiresOn`,\n gets an `expired` change and (unless it was renewed) the event. That\n includes a ticket that runs out while suspended, or while a revocation is\n recorded for a later day (a notice period). Only a row stored `valid` has\n its stored status set to `expired`; a stored `suspended` or `revoked`\n stays. Those rows are re-read on every run, so a scheduled sweep should\n pass `{ since }` (a day on or before the last day its previous run\n covered) to bound the work. `expiringWithin(days, today,\n { employedOnly })` lists rows that are good on `today` by the same dated\n evaluation as `check` (issued by `today`, not suspended or revoked on it,\n not renewed) and can keep only people employed on `today`.\n- **Events need the root handle.** A mutation begins its own transaction\n with `db.beginTransaction()`, commits it, then delivers its events.\n `@happyvertical/sql` has no after-commit hook and no marker for \"inside a\n transaction\" (happyvertical/sdk#1249), so on a handle that is already\n inside one (the `tx` of `db.transaction()`, a savepoint scope, or a\n `beginTransaction()` handle) the commit would be the caller's and an outer\n rollback could follow a delivered event. `HrService.transact` therefore\n refuses a mutation on such a handle with `HR_TRANSACTION_UNSUPPORTED`\n before writing anything. It recognises one by asking it to begin: a\n transaction-scoped handle either has no `beginTransaction` (libsql SQLite,\n PostgreSQL, DuckDB, JSON) or exposes one that throws\n `NestedTransactionError` without touching the connection (the\n native-capabilities SQLite adapter, opened for `vector` /\n `notifications`). Never go back to `db.transaction()` /\n `withEmbeddedWriteTransaction` here: on a transaction-scoped handle those\n re-enter the caller's transaction under a savepoint, or with\n `reuseUnsupportedNested` run on the caller's handle, and both fail open.\n Reads work on any handle. Tests that isolate with\n `createIsolatedTestDb*()` must hand the services `baseDb`, not the\n transaction handle.\n- **Tested on SQLite and PostgreSQL.** `HrService.identity` normalizes ids\n only on PostgreSQL. On any other adapter (DuckDB included) nothing is\n normalized, so callers must pass canonical lowercase UUIDs.\n- **Not a permission system.** A qualification says what a person may do in\n the real world. Who may use the software stays with `smrt-users`.\n- **People are `smrt-profiles:Profile` ids** in fields named `profileId` or\n `<role>ProfileId`, as cross-package refs. This is the id `smrt-timesheets`\n holds.\n\n## Extending Employment\n\nDeclare a same-named subclass over the `employments` table in the application,\nrestating the fields and the closed surface, and add application fields there\n(teamworks-os keeps succession rank and acting-role fields this way). Only one\ninheritance family may share a table; the deepest same-named subclass wins.\n\n`Employment.save()` lets such a subclass write its own fields: without the\nservice's write capability a save is allowed only for an existing row whose\npackage-owned columns (`tenantId`, `profileId`, `userId`, `employerProfileId`,\n`employeeNumber`, `workerType`, `position`, `status`) all still hold their\nstored values. A save that changes one of them, and any insert, throws\n`HR_WRITE_FORBIDDEN`; `delete()` always throws. Add a column to\n`PACKAGE_OWNED_COLUMNS` in `src/employment/models.ts` when the package gains\none.\n\n## Svelte UI\n\n`@happyvertical/smrt-human-resources/svelte` ships four components, each\nregistered as a slot in `HUMAN_RESOURCES_UI_SLOTS` (`src/ui.ts`):\n\n| Component | Slot | Shows |\n|---|---|---|\n| `EmployeeList` | `employee-list` | Name, employee number, position, worker type, status (plus \"Last day {date}\" when `endsOn` is set), start date |\n| `EmployeeForm` | `employee-form` | Employee number, worker type, position, start date (new hire only), effective date (edit only), optional login |\n| `PersonQualifications` | `person-qualifications` | One person's qualifications with their expiry state |\n| `ExpiringQualificationsList` | `expiring-qualifications-list` | Qualifications expiring soon across people, soonest first |\n\n- **Props-driven.** Components never load or save. The host calls the\n services, adapts rows with the helpers in `src/svelte/types.ts`\n (`toEmployeeView`, `toPersonQualificationView`,\n `toExpiringQualificationView`) and handles `onselect` / `onsubmit`.\n- **No clock in components.** `EmployeeList` shows the stored status badge\n and, when the host passes `endsOn` (the latest term's `endedOn`, fourth\n argument of `toEmployeeView`), the text \"Last day {date}\". It never compares\n a date with today to decide a label.\n- **Names come from the host.** HR holds no identity data, so every view\n carries a `displayName` the host supplies.\n- **`EmployeeForm` is hand-built** from smrt-ui form primitives (owner\n decision); the package does not depend on `smrt-fields` or `smrt-users`.\n- **`EmployeeForm` values are not a service input.** A hire returns\n `startedOn`; an edit returns a required `effectiveOn` (prefilled from\n `today`) that the host passes to `changePosition` / `changeWorkerType` /\n `linkLogin` / `unlinkLogin` for each value that changed. A login link or\n unlink takes effect at once whatever the date, so a future-dated edit that\n also clears the login unlinks it immediately; a host that wants it kept\n until then calls `unlinkLogin` on that day. The employee's\n current login stays an option even when the host's `logins` omit it, so a\n save never unlinks it by accident. Each validation message is tied to its\n field with `aria-describedby`.\n- **Day arithmetic is UTC** (`daysUntil`); never build a local-time `Date`\n from a calendar date.\n- **Expiry state is text plus a badge**, never colour alone. \"Expiring soon\"\n is a valid qualification whose last day is within `expiringSoonDays`\n (default 30), inclusive.\n- **Strings** go through `useI18n()` and the catalog in `src/svelte/i18n.ts`,\n keyed `human_resources.<component>.<key>`. Only smrt-ui primitives; no raw\n `<input>` / `<button>` / `<select>`.\n\n## Validation commands\n\n```bash\npnpm --filter @happyvertical/smrt-human-resources build\npnpm --filter @happyvertical/smrt-human-resources test\npnpm --filter @happyvertical/smrt-human-resources typecheck\n```\n\nBuild before `test`: `src/__tests__/employment-extension.test.ts` reads\n`dist/manifest.json`.\n",
|
|
915
|
+
"browser": {
|
|
916
|
+
"status": "server-only",
|
|
917
|
+
"issues": [
|
|
918
|
+
"#3624"
|
|
919
|
+
],
|
|
920
|
+
"via": [
|
|
921
|
+
"@happyvertical/smrt-tenancy"
|
|
922
|
+
]
|
|
923
|
+
}
|
|
915
924
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-human-resources",
|
|
3
|
-
"version": "0.55.
|
|
3
|
+
"version": "0.55.8",
|
|
4
4
|
"smrtJsdoc": "strict",
|
|
5
5
|
"description": "HR records for the SMRT framework: who works for an organization, as what, and with what standing — employment with dated terms, and qualifications with expiry",
|
|
6
6
|
"type": "module",
|
|
@@ -30,12 +30,12 @@
|
|
|
30
30
|
"./manifest.json": "./dist/manifest.json"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@happyvertical/logger": "^0.
|
|
34
|
-
"@happyvertical/smrt-core": "0.55.
|
|
35
|
-
"@happyvertical/smrt-tenancy": "0.55.
|
|
36
|
-
"@happyvertical/smrt-types": "0.55.
|
|
37
|
-
"@happyvertical/smrt-ui": "0.55.
|
|
38
|
-
"@happyvertical/sql": "^0.
|
|
33
|
+
"@happyvertical/logger": "^0.103.1",
|
|
34
|
+
"@happyvertical/smrt-core": "0.55.8",
|
|
35
|
+
"@happyvertical/smrt-tenancy": "0.55.8",
|
|
36
|
+
"@happyvertical/smrt-types": "0.55.8",
|
|
37
|
+
"@happyvertical/smrt-ui": "0.55.8",
|
|
38
|
+
"@happyvertical/sql": "^0.103.1"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"svelte": "^5.57.0"
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
}
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@happyvertical/smrt-vitest": "0.55.
|
|
49
|
+
"@happyvertical/smrt-vitest": "0.55.8",
|
|
50
50
|
"@sveltejs/package": "^2.5.8",
|
|
51
51
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
|
52
52
|
"@types/node": "24.13.6",
|