@happyvertical/smrt-human-resources 0.57.33 → 0.57.35

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.
@@ -3,13 +3,13 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-human-resources",
6
- "packageVersion": "0.57.33",
6
+ "packageVersion": "0.57.35",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "a7b2d70cf54683b597705609197b8540e7d0aa7c4186fc7631d89fad5ec3db3a",
11
- "packageJson": "b75ec6e11d22e23572bf00c0195e08101081f044985f0c6f7f5b0fab539e86b8",
12
- "agents": "e483ddf314195cbdca6170106d041472c97d95a6b9164535791c7176cb813655"
10
+ "manifest": "e57277bd7c8fa0039b4a3963491b69630e0cb6e6d649446d0f8663adf0689336",
11
+ "packageJson": "946dbdd31bb1c49bfdb6885231f9cd6c99caf2b5ab3bfdb25a751f236be1822a",
12
+ "agents": "39e33413cdd174e5fd877f57fd119a4246f3f8795095e356d3df46e42a9b6345"
13
13
  },
14
14
  "exports": [
15
15
  ".",
@@ -80,7 +80,8 @@
80
80
  "type": "crossPackageRef",
81
81
  "required": false,
82
82
  "related": "@happyvertical/smrt-profiles:Profile",
83
- "columnType": "UUID"
83
+ "columnType": "UUID",
84
+ "description": "The person who works for you."
84
85
  },
85
86
  {
86
87
  "name": "userId",
@@ -100,25 +101,29 @@
100
101
  "name": "employeeNumber",
101
102
  "type": "text",
102
103
  "required": false,
103
- "columnType": "TEXT"
104
+ "columnType": "TEXT",
105
+ "description": "Your own reference for this person, such as a staff number."
104
106
  },
105
107
  {
106
108
  "name": "workerType",
107
109
  "type": "text",
108
110
  "required": false,
109
- "columnType": "TEXT"
111
+ "columnType": "TEXT",
112
+ "description": "Whether they are an employee, contractor or another kind of worker."
110
113
  },
111
114
  {
112
115
  "name": "position",
113
116
  "type": "text",
114
117
  "required": false,
115
- "columnType": "TEXT"
118
+ "columnType": "TEXT",
119
+ "description": "Their role or trade."
116
120
  },
117
121
  {
118
122
  "name": "status",
119
123
  "type": "text",
120
124
  "required": false,
121
- "columnType": "TEXT"
125
+ "columnType": "TEXT",
126
+ "description": "Whether they currently work for you."
122
127
  }
123
128
  ],
124
129
  "relationships": [
@@ -206,25 +211,29 @@
206
211
  "type": "foreignKey",
207
212
  "required": false,
208
213
  "related": "Employment",
209
- "columnType": "UUID"
214
+ "columnType": "UUID",
215
+ "description": "The person this period of work belongs to."
210
216
  },
211
217
  {
212
218
  "name": "startedOn",
213
219
  "type": "text",
214
220
  "required": false,
215
- "columnType": "TEXT"
221
+ "columnType": "TEXT",
222
+ "description": "The first day of this period of work."
216
223
  },
217
224
  {
218
225
  "name": "endedOn",
219
226
  "type": "text",
220
227
  "required": false,
221
- "columnType": "TEXT"
228
+ "columnType": "TEXT",
229
+ "description": "The last day of this period of work. Empty while it continues."
222
230
  },
223
231
  {
224
232
  "name": "endReason",
225
233
  "type": "text",
226
234
  "required": false,
227
- "columnType": "TEXT"
235
+ "columnType": "TEXT",
236
+ "description": "Why the period of work ended."
228
237
  }
229
238
  ],
230
239
  "relationships": [
@@ -479,32 +488,37 @@
479
488
  "name": "name",
480
489
  "type": "text",
481
490
  "required": false,
482
- "columnType": "TEXT"
491
+ "columnType": "TEXT",
492
+ "description": "What the qualification is called, such as First aid."
483
493
  },
484
494
  {
485
495
  "name": "kind",
486
496
  "type": "text",
487
497
  "required": false,
488
- "columnType": "TEXT"
498
+ "columnType": "TEXT",
499
+ "description": "The sort of qualification, such as a certificate or a licence."
489
500
  },
490
501
  {
491
502
  "name": "issuingBody",
492
503
  "type": "text",
493
504
  "required": false,
494
- "columnType": "TEXT"
505
+ "columnType": "TEXT",
506
+ "description": "Who gives it out."
495
507
  },
496
508
  {
497
509
  "name": "expires",
498
510
  "type": "boolean",
499
511
  "required": true,
500
512
  "columnType": "BOOLEAN",
501
- "default": false
513
+ "default": false,
514
+ "description": "Whether it has to be renewed."
502
515
  },
503
516
  {
504
517
  "name": "validityMonths",
505
518
  "type": "integer",
506
519
  "required": false,
507
- "columnType": "INTEGER"
520
+ "columnType": "INTEGER",
521
+ "description": "How many months it lasts once given."
508
522
  },
509
523
  {
510
524
  "name": "scope",
@@ -582,14 +596,16 @@
582
596
  "type": "foreignKey",
583
597
  "required": false,
584
598
  "related": "Qualification",
585
- "columnType": "UUID"
599
+ "columnType": "UUID",
600
+ "description": "The qualification this person holds."
586
601
  },
587
602
  {
588
603
  "name": "profileId",
589
604
  "type": "crossPackageRef",
590
605
  "required": false,
591
606
  "related": "@happyvertical/smrt-profiles:Profile",
592
- "columnType": "UUID"
607
+ "columnType": "UUID",
608
+ "description": "The person who holds it."
593
609
  },
594
610
  {
595
611
  "name": "employmentId",
@@ -602,25 +618,29 @@
602
618
  "name": "issuedOn",
603
619
  "type": "text",
604
620
  "required": false,
605
- "columnType": "TEXT"
621
+ "columnType": "TEXT",
622
+ "description": "The day it was given."
606
623
  },
607
624
  {
608
625
  "name": "expiresOn",
609
626
  "type": "text",
610
627
  "required": false,
611
- "columnType": "TEXT"
628
+ "columnType": "TEXT",
629
+ "description": "The day it runs out. Empty if it does not."
612
630
  },
613
631
  {
614
632
  "name": "certificateNumber",
615
633
  "type": "text",
616
634
  "required": false,
617
- "columnType": "TEXT"
635
+ "columnType": "TEXT",
636
+ "description": "The number printed on the certificate."
618
637
  },
619
638
  {
620
639
  "name": "status",
621
640
  "type": "text",
622
641
  "required": false,
623
- "columnType": "TEXT"
642
+ "columnType": "TEXT",
643
+ "description": "Whether it is valid, expired, suspended or revoked."
624
644
  },
625
645
  {
626
646
  "name": "verifiedByProfileId",
@@ -911,7 +931,254 @@
911
931
  "polymorphicAssociations": 0,
912
932
  "uuidColumns": 52
913
933
  },
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 would inherit the model's closed surface; the explicit\n literal keeps it closed whatever the model says. 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",
934
+ "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 would inherit the model's closed surface; the explicit\n literal keeps it closed whatever the model says. 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\n- **Recipes `human-resources.people` (label Employees) and `human-resources.qualifications`** (`src/recipes.ts`, help in `src/people.recipe.md` and `src/qualifications.recipe.md`) are `static exposure = 'ui-only'` (#4440): the models stay closed to API/MCP/CLI, the recipes still appear in the planner and generate screens, and the Features card says agents cannot read them. The scanner rejects `ui-only` if any model of the recipe is exposed. Never open a surface to make a recipe show up.\n",
935
+ "recipes": [
936
+ {
937
+ "id": "human-resources.people",
938
+ "className": "HumanResourcesPeopleRecipe",
939
+ "label": "Employees",
940
+ "summary": "Keep a record of who works for you and in what role. Only people can see it; agents cannot read it.",
941
+ "synonyms": [
942
+ "hr",
943
+ "human resources",
944
+ "staff",
945
+ "people",
946
+ "team",
947
+ "workers"
948
+ ],
949
+ "models": [
950
+ "@happyvertical/smrt-human-resources:Employment",
951
+ "@happyvertical/smrt-human-resources:EmploymentTerm"
952
+ ],
953
+ "nav": [
954
+ {
955
+ "label": "Employees",
956
+ "model": "@happyvertical/smrt-human-resources:Employment",
957
+ "icon": "users",
958
+ "description": "Everyone who works for you, with their role and status.",
959
+ "noun": "employee"
960
+ }
961
+ ],
962
+ "requires": [],
963
+ "section": {
964
+ "id": "staff",
965
+ "label": "Staff",
966
+ "icon": "users",
967
+ "description": "The people who work for you."
968
+ },
969
+ "options": {
970
+ "@happyvertical/smrt-human-resources:Employment": {
971
+ "fields": {
972
+ "profileId": {
973
+ "label": "Person",
974
+ "order": 1
975
+ },
976
+ "employeeNumber": {
977
+ "order": 2
978
+ },
979
+ "position": {
980
+ "order": 3
981
+ },
982
+ "workerType": {
983
+ "order": 4
984
+ },
985
+ "status": {
986
+ "order": 5
987
+ },
988
+ "tenantId": {
989
+ "visibility": "hidden"
990
+ },
991
+ "userId": {
992
+ "visibility": "advanced"
993
+ },
994
+ "employerProfileId": {
995
+ "visibility": "advanced"
996
+ }
997
+ }
998
+ },
999
+ "@happyvertical/smrt-human-resources:EmploymentTerm": {
1000
+ "fields": {
1001
+ "employmentId": {
1002
+ "label": "Person",
1003
+ "order": 1
1004
+ },
1005
+ "startedOn": {
1006
+ "label": "First day",
1007
+ "order": 2
1008
+ },
1009
+ "endedOn": {
1010
+ "label": "Last day",
1011
+ "order": 3
1012
+ },
1013
+ "endReason": {
1014
+ "order": 4
1015
+ },
1016
+ "tenantId": {
1017
+ "visibility": "hidden"
1018
+ }
1019
+ }
1020
+ }
1021
+ },
1022
+ "help": {
1023
+ "markdown": "## Overview\n\nEmployees is where you keep a record of who works for you. Each person has an\nemployment record with their role, their worker type and whether they are\ncurrently active, plus the dates they started and, if they left, finished.\n\nOnly people using the app can see these records. They are kept closed to\nassistants and other automated tools on purpose, so an agent cannot read them.\n\n## Tasks\n\n### Add an employee\n\n1. Open the list of employees and choose **New**.\n2. Pick the **{field:Employment.profileId}**.\n3. Give them an **{field:Employment.employeeNumber}**, your own reference for them.\n4. Fill in their **{field:Employment.position}** and **{field:Employment.workerType}**.\n5. Save. They now show in your list of employees.\n\n### Record that someone has left\n\n1. Open the person from the list.\n2. Set the **{field:Employment.status}** to show they no longer work for you.\n3. Record their **{field:EmploymentTerm.endedOn}** and the **{field:EmploymentTerm.endReason}**.\n4. Save. Their history stays on file.\n",
1024
+ "fieldRefs": [
1025
+ "Employment.employeeNumber",
1026
+ "Employment.position",
1027
+ "Employment.profileId",
1028
+ "Employment.status",
1029
+ "Employment.workerType",
1030
+ "EmploymentTerm.endReason",
1031
+ "EmploymentTerm.endedOn"
1032
+ ]
1033
+ },
1034
+ "exposure": "ui-only",
1035
+ "demo": {
1036
+ "mode": "server",
1037
+ "reasons": [
1038
+ "Needs a server because its package depends on @happyvertical/smrt-tenancy, which do not build for a browser (#3624)."
1039
+ ]
1040
+ }
1041
+ },
1042
+ {
1043
+ "id": "human-resources.qualifications",
1044
+ "className": "HumanResourcesQualificationsRecipe",
1045
+ "label": "Qualifications",
1046
+ "summary": "Track the certificates and training your people hold, and when they run out. Only people can see it; agents cannot read it.",
1047
+ "synonyms": [
1048
+ "certifications",
1049
+ "certificates",
1050
+ "licences",
1051
+ "training",
1052
+ "first aid"
1053
+ ],
1054
+ "models": [
1055
+ "@happyvertical/smrt-human-resources:Qualification",
1056
+ "@happyvertical/smrt-human-resources:HeldQualification"
1057
+ ],
1058
+ "nav": [
1059
+ {
1060
+ "label": "Qualifications",
1061
+ "model": "@happyvertical/smrt-human-resources:Qualification",
1062
+ "icon": "book",
1063
+ "description": "The certificates, licences and training your people can hold.",
1064
+ "noun": "qualification"
1065
+ },
1066
+ {
1067
+ "label": "Held qualifications",
1068
+ "model": "@happyvertical/smrt-human-resources:HeldQualification",
1069
+ "icon": "fileText",
1070
+ "description": "Who holds which qualification, and when it runs out.",
1071
+ "noun": "held qualification"
1072
+ }
1073
+ ],
1074
+ "requires": [
1075
+ "human-resources.people"
1076
+ ],
1077
+ "section": {
1078
+ "id": "staff",
1079
+ "label": "Staff",
1080
+ "icon": "users",
1081
+ "description": "The people who work for you."
1082
+ },
1083
+ "options": {
1084
+ "@happyvertical/smrt-human-resources:Qualification": {
1085
+ "fields": {
1086
+ "name": {
1087
+ "order": 1
1088
+ },
1089
+ "kind": {
1090
+ "order": 2
1091
+ },
1092
+ "issuingBody": {
1093
+ "order": 3
1094
+ },
1095
+ "expires": {
1096
+ "order": 4
1097
+ },
1098
+ "validityMonths": {
1099
+ "order": 5
1100
+ },
1101
+ "key": {
1102
+ "visibility": "advanced"
1103
+ },
1104
+ "scope": {
1105
+ "visibility": "advanced"
1106
+ },
1107
+ "isActive": {
1108
+ "visibility": "advanced"
1109
+ },
1110
+ "tenantId": {
1111
+ "visibility": "hidden"
1112
+ }
1113
+ }
1114
+ },
1115
+ "@happyvertical/smrt-human-resources:HeldQualification": {
1116
+ "fields": {
1117
+ "qualificationId": {
1118
+ "label": "Qualification",
1119
+ "order": 1
1120
+ },
1121
+ "profileId": {
1122
+ "label": "Person",
1123
+ "order": 2
1124
+ },
1125
+ "issuedOn": {
1126
+ "order": 3
1127
+ },
1128
+ "expiresOn": {
1129
+ "order": 4
1130
+ },
1131
+ "certificateNumber": {
1132
+ "order": 5
1133
+ },
1134
+ "status": {
1135
+ "order": 6
1136
+ },
1137
+ "tenantId": {
1138
+ "visibility": "hidden"
1139
+ },
1140
+ "employmentId": {
1141
+ "visibility": "advanced"
1142
+ },
1143
+ "verifiedByProfileId": {
1144
+ "visibility": "advanced"
1145
+ },
1146
+ "verifiedAt": {
1147
+ "visibility": "advanced"
1148
+ },
1149
+ "documentAssetId": {
1150
+ "visibility": "advanced"
1151
+ },
1152
+ "renewalOfId": {
1153
+ "visibility": "hidden"
1154
+ }
1155
+ }
1156
+ }
1157
+ },
1158
+ "help": {
1159
+ "markdown": "## Overview\n\nQualifications is where you list the certificates, licences and training your\nwork needs, such as first aid, and record who holds each one and when it runs\nout. It builds on Employees, so switch that on first.\n\nOnly people using the app can see these records. They are kept closed to\nassistants and other automated tools on purpose, so an agent cannot read them.\n\n## Tasks\n\n### Set up a qualification\n\n1. Open the list of qualifications and choose **New**.\n2. Type the **{field:Qualification.name}**, for example \"First aid\".\n3. Say who gives it out in **{field:Qualification.issuingBody}**.\n4. Tick **{field:Qualification.expires}** if it has to be renewed, and set **{field:Qualification.validityMonths}** to how long it lasts.\n5. Save.\n\n### Record who holds a qualification\n\n1. Open the held qualifications and choose **New**.\n2. Pick the **{field:HeldQualification.qualificationId}** and the **{field:HeldQualification.profileId}** who holds it.\n3. Enter the date it was **{field:HeldQualification.issuedOn}** and when it **{field:HeldQualification.expiresOn}**.\n4. Add the **{field:HeldQualification.certificateNumber}** from the certificate.\n5. Set the **{field:HeldQualification.status}** to show whether it is still valid.\n6. Save. You can now see who needs to renew before the date.\n",
1160
+ "fieldRefs": [
1161
+ "HeldQualification.certificateNumber",
1162
+ "HeldQualification.expiresOn",
1163
+ "HeldQualification.issuedOn",
1164
+ "HeldQualification.profileId",
1165
+ "HeldQualification.qualificationId",
1166
+ "HeldQualification.status",
1167
+ "Qualification.expires",
1168
+ "Qualification.issuingBody",
1169
+ "Qualification.name",
1170
+ "Qualification.validityMonths"
1171
+ ]
1172
+ },
1173
+ "exposure": "ui-only",
1174
+ "demo": {
1175
+ "mode": "server",
1176
+ "reasons": [
1177
+ "Needs a server because its package depends on @happyvertical/smrt-tenancy, which do not build for a browser (#3624)."
1178
+ ]
1179
+ }
1180
+ }
1181
+ ],
915
1182
  "browser": {
916
1183
  "status": "server-only",
917
1184
  "issues": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-human-resources",
3
- "version": "0.57.33",
3
+ "version": "0.57.35",
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",
@@ -31,10 +31,10 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@happyvertical/logger": "^0.108.0",
34
- "@happyvertical/smrt-core": "0.57.33",
35
- "@happyvertical/smrt-tenancy": "0.57.33",
36
- "@happyvertical/smrt-types": "0.57.33",
37
- "@happyvertical/smrt-ui": "0.57.33",
34
+ "@happyvertical/smrt-core": "0.57.35",
35
+ "@happyvertical/smrt-tenancy": "0.57.35",
36
+ "@happyvertical/smrt-types": "0.57.35",
37
+ "@happyvertical/smrt-ui": "0.57.35",
38
38
  "@happyvertical/sql": "^0.108.0"
39
39
  },
40
40
  "peerDependencies": {
@@ -46,7 +46,7 @@
46
46
  }
47
47
  },
48
48
  "devDependencies": {
49
- "@happyvertical/smrt-vitest": "0.57.33",
49
+ "@happyvertical/smrt-vitest": "0.57.35",
50
50
  "@sveltejs/package": "^2.5.8",
51
51
  "@sveltejs/vite-plugin-svelte": "^7.3.0",
52
52
  "@types/node": "24.13.6",