# The Qualifications kit — licences, certificates, inductions and signed waivers a person holds

> `Osysharp.Qualifications` keeps what a person holds that lets them do something — a licence, a certificate, an induction, a background check, a signed waiver — with a validity in months, who recorded or signed it, the evidence behind it, and one call answering whether somebody holds it on a day and, when not, why not in a sentence. Waivers are signed on the exact text version shown; staff verify what people submit; a notice goes out before each lapses. Not sign-in.

<!-- id: ui-qualifications-kit · area: ui · stability: preview · html: https://osysharp.com/reference/ui/qualifications-kit/ -->

## Summary        {#summary}
`use Osysharp.Qualifications@0;` keeps the qualifications people hold. The app says who records them:

```osy syntax
policy IssuesQualifications => IsStaff;          // record, verify and revoke qualifications, read everybody's
policy ManagesQualificationKinds => IsAdmin;     // what kinds exist, and the text a waiver is signed on
```

Then one call answers anywhere a qualification matters — a door, a booking, a shift: `QualificationGap(person,
["site-induction", "hire-waiver"])` is `""` when they hold both, or the sentence saying what is missing.

## Signature      {#signature}
```osy syntax
virtual policy IssuesQualifications => false;                virtual policy ManagesQualificationKinds => IssuesQualifications;
virtual policy ChecksQualifications => IssuesQualifications;    virtual policy ActsForHolder(Principal holder) => holder == user;
app.Qualifications = new QualificationsSetup { RenewalNoticeDays = 30, HoldersSubmit = true };

public entity QualificationKind { string Key; string Name; QualificationCategory Category; int? ValidForMonths; int? NoticeDays;
                               bool SignedByHolder; bool NeedsEvidence; bool Active; }
Qualification RecordQualification(Principal holder, string kindKey, DateOnly? issuedOn = null, string reference = "", string issuedBy = "", string note = "");
Qualification SignQualification(Principal holder, QualificationText text, string signedName);
Qualification SubmitQualification(Principal holder, string kindKey, DateOnly issuedOn, string reference = "", string issuedBy = "");
FileAsset KeepEvidence(UploadedFile upload);           QualificationEvidence AttachEvidence(Qualification c, FileAsset file);
void VerifyQualification(Qualification c, string note = "");  void RejectQualification(Qualification c, string reason);
void RevokeQualification(Qualification c, string reason);    QualificationText PublishQualificationText(QualificationKind kind, string body);
QualificationCheck CheckQualification(Principal holder, string kindKey, DateOnly? on = null);   // State, IsValid, ExpiresSoon, ValidUntil, Reason
bool HasValidQualification(Principal holder, string kindKey, DateOnly? on = null);
List<QualificationCheck> MissingQualifications(Principal holder, List<string> kindKeys, DateOnly? on = null);
string QualificationGap(Principal holder, List<string> kindKeys, DateOnly? on = null);
List<Qualification> LapsingWithin(int days);   List<Qualification> LapsedWithin(int days);   List<Qualification> AwaitingVerification();
```

## Description    {#description}

### What is a qualification?   {#qualifications}
One person's qualification of one `QualificationKind`, from `IssuedOn` through `ValidUntil` — the LAST day it holds, the
kind's months after issue less a day (3 May + 12 months holds through 2 May; month ends clamp), or none for a kind that
does not lapse. It carries the number on it, who issued it outside the app, who recorded it, and — for a signed one —
the text version, who signed, the name they typed and when. It is never edited into something else: a renewal is a new
qualification, and revoking or turning one down keeps the row with the reason. Its status changes only through the kit's
functions (`deny update Status except via …`).

### How is one put on record?   {#recording}
Three ways. **Staff record it** (`RecordQualification`) — an induction they gave, a licence they saw — in force at once.
**The person signs it** (`SignQualification`) for a kind marked `SignedByHolder`: a waiver, a declaration. **The person
submits it** (`SubmitQualification`) with evidence — a scan or photo kept with `KeepEvidence` and attached with
`AttachEvidence`, readable wherever the qualification is — and it counts once staff `VerifyQualification` it (a kind with
`NeedsEvidence` refuses verification with no file) or says why with `RejectQualification`.

### How does a waiver keep what was signed?   {#waivers}
Its kind's text is a `QualificationText` with a version, never changed: `PublishQualificationText` makes the next version.
`SignQualification` takes the version the person was shown and refuses if it is no longer the current one, so nobody signs a
wording they did not read; the signature keeps that version. `ActsForHolder(holder)` says who may sign for whom — a
parent for a child with a managed account of [Osysharp.UserAccounts](https://osysharp.com/reference/security/accounts-kit/).

### How is it checked?   {#checking}
`CheckQualification(holder, kindKey, on)` answers the state — `Valid`, `Expired`, `Missing`, `Revoked` or `Waiting` — with
`ExpiresSoon`, the last day, and a sentence: *Safety briefing is valid until 28 February 2027.*, *Skipper's licence
expired on 30 September 2026.*, *Hire waiver has not been signed.*, *First aid certificate was revoked: number did not
check out.* Of several, the most favourable decides. It reads as the caller: a person about themself or somebody they
act for, `ChecksQualifications` about anybody. `QualificationGap` and `MissingQualifications` ask several at once — the call to
block anything that needs them.

### What happens before and at a lapse?   {#lapse}
Each qualification's own run (`QualificationLife`) raises `QualificationExpiringSoon` its kind's `NoticeDays` (else the app's
`RenewalNoticeDays`) before it lapses, and `QualificationExpired` when it does — both only when nothing newer of its kind
replaces it, so a person who renewed in time hears nothing. An old qualification entered already lapsed is not news.
`LapsingWithin(days)` and `LapsedWithin(days)` are the lists a desk works from.

### Which pages does it serve?   {#pages}
`/qualifications` — what the signed-in person holds, until when, the waivers waiting for their signature (the full text,
an "I have read it and agree" tick and their typed name), and adding one of their own with its file.
`/staff/qualifications` — what lapses within 30 days, what lapsed, what waits for verification (with the files), and any
person's record: record one, revoke one. `/staff/qualifications/kinds` — the kinds and each waiver's text versions. Each
stands in the app's working shell with theme tokens only; take one over by declaring a component of the same name, or
place `QualificationsOfPerson(person)` — a guardian's page signing for a child — `QualificationsDesk` or `QualificationKinds` on
a page of your own.

### Whose data is kept, and how does it go?   {#privacy}
The number on a qualification, the typed signature and why one was revoked are `Personal.Content`. With Osysharp.Privacy,
`Osysharp.Privacy.Qualifications` arrives by itself ([Bridges — a package that arrives when an app uses the two it completes](https://osysharp.com/reference/project/bridges/)) and counts, copies (with the exact text signed)
and erases a person's qualifications and their files.

## Examples       {#examples}

### A warehouse that checks a licence before a shift on the trucks   {#example-warehouse}
```osy title="a warehouse: forklift licences recorded by supervisors, a site induction signed by each starter, and the check before driving" test app=ui-qualifications-kit
app Warehouse {
  use Osysharp.Identity@0;
  use Osysharp.Qualifications@0;
  use Osysharp.Storage;
  use Osysharp.Ui;
  use Osysharp.Workflow;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.Qualifications;

[Role] enum DepotRole { Authenticator, Picker, Supervisor }
policy IsSupervisor => RoleGrant.Any(g => g.Member == user && g.Role == DepotRole.Supervisor);
policy IssuesQualifications => IsSupervisor;

app.Qualifications = new QualificationsSetup { RenewalNoticeDays = 45, HoldersSubmit = false };

/// Before a picker takes a truck: their licence and their induction, or the sentence saying which is missing.
[Server, Authorize(IsSupervisor)]
public string MayDrive(Principal picker) {
  var why = QualificationGap(picker, ["forklift-licence", "site-induction"]);
  return why == "" ? "Cleared to drive." : why;
}

/// A note for the supervisor when a licence is about to lapse.
entity Reminder {
  [Required("Whose licence?")] Principal Driver;
  DateOnly Lapses;
  security { allow read when IsSupervisor; }
}

[On]
void NoteTheLapse(QualificationExpiringSoon e) {
  new Reminder { Driver = e.Qualification.Holder, Lapses = e.Qualification.ValidUntil!.Value };
}
```

## See also       {#see-also}
- [The ShiftScheduling kit — a staff rota: shifts, places, time off, cover and hours](https://osysharp.com/reference/ui/shift-scheduling-kit/) — a position on the rota requiring a qualification
- [The UserAccounts kit — sign-in done properly, once](https://osysharp.com/reference/security/accounts-kit/) — an account for somebody who never signs in, whose waiver a guardian signs
- [[Attachment] — a file readable wherever its row is](https://osysharp.com/reference/storage/attachment/) — the evidence, readable wherever its qualification is
- [deny … except via](https://osysharp.com/reference/security/except-via/) — how a qualification's status changes only through the kit's functions
- [The Privacy kit — erasure that keeps only what the company must or may](https://osysharp.com/reference/security/privacy-kit/) — a person's qualifications in their privacy requests
- **`kits/qualifications/tests/`** — every behaviour above as a test, the screens under `--pixels`
