Last active
August 23, 2026 18:19
-
-
Save halcwb/0080593eec0d3af6a836df6a56f0044c to your computer and use it in GitHub Desktop.
Modelling main EHR - GenPRES - User session management
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // GenPRES – MainEHR Integration: the system model, executable | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // | |
| // A runnable model of the design document *GenPRES – MainEHR Integration*. The | |
| // document is leading: every type, message and branch below exists to carry one of | |
| // its Actors, Concepts, Constraints or Rules, and cites it by number. Nothing the | |
| // document does not sanction lives here. | |
| // | |
| // The file is standalone — no #load, no #r. Run it with: | |
| // | |
| // dotnet fsi Session.fsx | |
| // | |
| // It prints a trace per scenario and ends with a count of self-checks. | |
| // | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // SECTION 0 — THE SYSTEM MODEL, AS THE DOCUMENT STATES IT | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // | |
| // ── Actors ───────────────────────────────────────────────────────────────────── | |
| // [ours] = under construction. [given] = existing infrastructure, not ours to | |
| // change. The User is neither — they are who the system is for. | |
| // | |
| // 1. MainEHR Workstation [given] the running EHR Client | |
| // 2. MainEHR LaunchScript [ours] a VB.NET script behind a button in the | |
| // Workstation. Runs on trigger, then exits. | |
| // The only part of MainEHR we control. | |
| // 3. GenPRES Client [ours] GenPRES UI running in a Browser | |
| // 4. GenPRES Server [ours] GenPRES backend | |
| // 5. GenPRES Database [ours] holds the SessionRecords, PatientRecords and | |
| // UserCredentials. The Server is its only writer. | |
| // 6. PatientDataPlatform [given] a shared, read-only copy of the databases of | |
| // MainEHR, GenPRES and other applications | |
| // 7. User person who uses MainEHR and GenPRES | |
| // 8. Broker [ours] hands a launch from the LaunchScript to the Server | |
| // 9. UserRegistry [ours] says who a login belongs to, what that person | |
| // may do, and how to reach them by mail | |
| // 10. MailService [given] sends mail, outside GenPRES and outside MainEHR | |
| // | |
| // ── Roles ────────────────────────────────────────────────────────────────────── | |
| // The UserRegistry decides the Role. MainEHR and GenPRES enforce it independently. | |
| // | |
| // 1. Prescriber may read and write — writing meaning creating TreatmentPlans | |
| // 2. Reader may never create a TreatmentPlan. Like any User they may prescribe | |
| // within their Session (Concept 15), but nothing of it can be saved. | |
| // | |
| // ── Concepts ─────────────────────────────────────────────────────────────────── | |
| // | |
| // 1. UserContext User identification and User Role. | |
| // 2. PatientContext PatientId and Patient Data relevant for GenPRES. The User | |
| // can supply the data by hand; only a launch can supply the | |
| // identification. Launched, the data is read from the | |
| // PatientDataPlatform once, at the launch, and not refreshed | |
| // while the Session lives: in-Session staleness is accepted. | |
| // 3. LaunchAssertion asserts a MainEHR login, and the Patient if one is active | |
| // — no verified identity, no Role. | |
| // 4. LaunchCredential opaque single-use reference to a LaunchAssertion, short | |
| // lifetime. | |
| // 5. MainEHR Session the period a User is logged in at a Workstation. Many | |
| // Patients can be handled in it, one active at a time. | |
| // 6. MainEHR PatientRecord all patient data maintained by MainEHR. | |
| // 7. GenPRES UserCredential held by GenPRES for one User — the login by which the | |
| // UserRegistry knows that person, a PIN if one is set, and | |
| // the count of consecutive wrong PIN entries (Rule 27). The | |
| // PIN is optional: a UserCredential may hold none, either | |
| // because the User has never set one or because it was | |
| // removed, and one without a PIN cannot sign. It carries no | |
| // Role and no identity of its own. | |
| // 8. GenPRES Session the interaction of a User with GenPRES — for a Patient if | |
| // the launch supplied one, otherwise for no Patient; opened | |
| // without a launch, it is anonymous (Rule 13). Only a Session | |
| // with a Patient allows opening or creating TreatmentPlans | |
| // (Rule 12). A Session has no state in GenPRES Server between | |
| // requests: its identity and standing live in its | |
| // SessionRecord, its work in GenPRES Client (Rule 31). | |
| // 9. GenPRES SessionRecord binds a SessionId to exactly one User — the UserContext | |
| // as the launch established it, and the mail address — or to | |
| // no User, when anonymous — and to a Patient if it has one. | |
| // Records whether the Session is open or ended, when it last | |
| // heard from the Client (Rule 8), and whether the User has | |
| // been told about its ending. Kept after the Session ends. | |
| // 10. OrderContext a PatientContext together with the OrderScenarios currently | |
| // under consideration. It has an identity that persists across | |
| // TreatmentPlans, and carries the UserContext of the User whose | |
| // Session last changed it — stamped at each save (Rule 14), so | |
| // an OrderContext that is never saved carries none. | |
| // 11. OrderScenario one proposed Order together with the prescribing information | |
| // that gives it meaning but is not part of the Order itself. | |
| // 12. GenPRES PatientRecord the append-only history of a Patient in GenPRES — a | |
| // sequence of TreatmentPlans, every one carrying that Patient's | |
| // PatientId: the one thing no TreatmentPlan may change. | |
| // 13. TreatmentPlan the Patient's treatment plan as it stood when saved — a set of | |
| // their OrderContexts, carrying the UserContext of the User who | |
| // created it and a reference to the TreatmentPlan it was created | |
| // from — its base — if any. Either Signed or Unsigned. | |
| // 14. Saving and Signing one act — creating a TreatmentPlan. Signing is saving while | |
| // supplying the PIN of the Session's User: the TreatmentPlan is | |
| // then Signed, otherwise Unsigned. There is no other way a | |
| // TreatmentPlan comes into being. | |
| // 15. Prescribing changing, within a Session, the Patient Data of the | |
| // PatientContext and adding, removing or changing | |
| // OrderContexts. Prescribing touches only the Session, and the | |
| // work lives in GenPRES Client (Rule 31): nothing reaches the | |
| // PatientRecord until a TreatmentPlan is created, and the Server | |
| // computes on what the Client sends without keeping any of it. | |
| // | |
| // ── Constraints ──────────────────────────────────────────────────────────────── | |
| // Notation: | |
| // X -> Y X initiates a connection to Y and receives Y's response on it. Grants | |
| // initiation in that direction only; the reverse is never implied. | |
| // X => Y X launches Y with initial parameters. One-way: no response, no error | |
| // path back. | |
| // X <-> Y interaction, not request–response: a User can read what Y shows and | |
| // act on it. | |
| // Any pair without an edge cannot exchange data at all. Edges do not compose — no | |
| // component relays on another's behalf unless stated. | |
| // | |
| // User Interaction | |
| // U1. Any User <-> MainEHR Workstation | |
| // U2. Any User <-> MainEHR LaunchScript — the User starts it; while it runs it can | |
| // report its own acts back (the Broker | |
| // exchange, the launching of the browser), | |
| // and it exits at once, so nothing later | |
| // ever comes from it. | |
| // U3. Any User <-> GenPRES Client | |
| // | |
| // Communication | |
| // C1. MainEHR Workstation -> UserRegistry | |
| // C2. MainEHR Workstation -> PatientDataPlatform | |
| // C3. MainEHR LaunchScript -> Broker | |
| // C4. MainEHR LaunchScript => GenPRES Client | |
| // C5. GenPRES Client -> GenPRES Server | |
| // C6. GenPRES Server -> Broker | |
| // C7. GenPRES Server -> UserRegistry | |
| // C8. GenPRES Server -> PatientDataPlatform | |
| // C9. GenPRES Server -> GenPRES Database | |
| // C10. GenPRES Server -> MailService | |
| // | |
| // ── Consequences ─────────────────────────────────────────────────────────────── | |
| // Derived from the edges above — not new assertions. | |
| // | |
| // 1. The LaunchScript learns nothing after the launch. What it can report to the | |
| // User (User Interaction 2) ends with its own acts: the Broker exchange (UC-1 | |
| // ext 3a) and the launching of the browser. Expired credential, Server down, | |
| // wrong patient — none of it reaches it. Error handling falls to the Client, | |
| // except when the Server is unreachable: the Client is served by the Server, so | |
| // then no Client is served either and the User is left with the browser's error | |
| // page. | |
| // 2. The Broker is the only party both the LaunchScript and the Server can reach, | |
| // so it is the sole channel between the EHR side and GenPRES. | |
| // 3. Only the Broker knows whether a credential was redeemed, and it cannot tell | |
| // the LaunchScript, which has exited. | |
| // 4. The credential travels in a URL, so it lands in browser history, the address | |
| // bar, and possibly referrer and proxy logs — hence single use, short lifetime. | |
| // 5. Both the Workstation and the LaunchScript run on the User's PC, so their calls | |
| // originate there. Every workstation needs network access to the UserRegistry, | |
| // the PatientDataPlatform and the Broker, plus whatever secret authenticates it. | |
| // 6. The Server cannot reach a Client (edge C5 goes one way only), so a Client only | |
| // learns its Session ended at its next request. Until then it shows a | |
| // live-looking screen. | |
| // | |
| // ── Invariants ───────────────────────────────────────────────────────────────── | |
| // 1. A User has at most one active Patient at any moment in a MainEHR Session. | |
| // | |
| // ── Possibilities ────────────────────────────────────────────────────────────── | |
| // 1. Users can leave a logged in MainEHR Session open and another User can act in it. | |
| // 2. Multiple Users can have the same Patient active each in their own MainEHR Session. | |
| // | |
| // ── Rules ────────────────────────────────────────────────────────────────────── | |
| // What the [ours] components must enforce. One assertion each; grouped for reading, | |
| // numbered straight through for citing. | |
| // | |
| // Launch | |
| // 1. The LaunchScript decides which MainEHR User may run it. | |
| // 2. A LaunchCredential is accepted once; a second presentation is refused. | |
| // 3. A LaunchCredential is accepted only within its lifetime. | |
| // 4. Only the Server may redeem a LaunchCredential at the Broker. | |
| // 5. The Server takes the Role from the UserRegistry at each launch, never from | |
| // the launch itself. | |
| // 6. If a launch cannot be honoured — no credential, no Role, or a required PIN | |
| // not set (Rule 24) — no Session is opened by it. There is no silent fallback: | |
| // at most, the Client offers the User a fresh anonymous open (Rule 13; UC-8), | |
| // which carries nothing over from the launch — no User, no Patient. | |
| // | |
| // Session | |
| // 7. A User has at most one open Session; opening another closes the rest. The | |
| // limit is per User, not per Patient: two Users may each hold their own | |
| // Sessions for the same Patient at once. | |
| // 8. Every request from the Client refreshes its Session's idle clock. | |
| // 9. A Session ends when the User closes it, when it has been idle too long, when | |
| // the wrong-PIN limit is reached (Rule 27), or when that same User opens another | |
| // Session (Rule 7). Closing is an explicit act in the Client: a browser that | |
| // vanishes is indistinguishable from one gone quiet, so the Session is left to | |
| // idle out. A Server restart ends nothing: the Server holds no Session state to | |
| // lose (Rule 31). | |
| // 10. When a Session ends other than by the User closing it, the User is told at the | |
| // next opportunity: through any Client still holding that SessionId, at its next | |
| // request, or at the User's next launch. Told once, never twice. | |
| // 11. The SessionId is a bearer credential: it never travels in a URL. | |
| // 12. A Session without a PatientId lets the User prescribe (Concept 15), Patient | |
| // Data included, but a TreatmentPlan cannot be opened or created. | |
| // 13. A Session opened without a launch is anonymous: it binds to no User and | |
| // carries no UserContext, no Role, and no PatientId. Rules that speak of the | |
| // Session's User (7, 10) do not apply to it, and it need not idle out (Rule 9): | |
| // it may remain open indefinitely, as keeping it has no consequence. | |
| // | |
| // Record | |
| // 14. Every TreatmentPlan is created under the credentials of exactly one User — the | |
| // Session's — and carries that User's identity. Within it, every OrderContext | |
| // changed in the Session is stamped with that same UserContext; an unchanged | |
| // OrderContext keeps the stamp it had. | |
| // 15. A TreatmentPlan is either Signed or Unsigned. | |
| // 16. Only the most recent Signed TreatmentPlan counts clinically. | |
| // 17. The most recent Signed TreatmentPlan is the only one another User can open — | |
| // older TreatmentPlans are record, not views. | |
| // 18. Only the User who created an Unsigned TreatmentPlan can open that TreatmentPlan. | |
| // 19. A User can only start with the most recent TreatmentPlan that is either Signed or | |
| // Unsigned and their own. Where neither exists, the User works from nothing. | |
| // 20. A User may create a new TreatmentPlan, unless a Signed one exists that is newer | |
| // than the TreatmentPlan the User opened with — the one most recently opened in | |
| // the Session, so opening the newest Signed one lifts the block (UC-6). | |
| // | |
| // Notification | |
| // 21. If a User is about to create a TreatmentPlan and an Unsigned one of another User | |
| // exists that is newer than the TreatmentPlan the User opened with — any | |
| // TreatmentPlan at all, where the User opened with nothing — the User is notified — | |
| // told whose work it is, not its contents — and may choose not to create. | |
| // | |
| // Signing | |
| // 22. The Server is the only party that verifies a UserCredential; the PIN never | |
| // leaves GenPRES. | |
| // 23. Every launch checks whether a PIN is set for the login. | |
| // 24. A Prescriber with no PIN must set one before the launch continues, and only | |
| // after the UserRegistry has recognised their login. | |
| // 25. A Reader is never asked for a PIN: a Reader never creates a TreatmentPlan, so they | |
| // have nothing to prove. | |
| // 26. The Server mails the User and records the change on every setting of a PIN | |
| // and every removal of one, the first setting included. The address comes from | |
| // the UserRegistry. | |
| // 27. Wrong PIN entries count per UserCredential, across Sessions: a wrong entry at | |
| // the configurable limit ends the Session (Rule 9). A correct entry resets the | |
| // count, and a newly set PIN (Rule 26) starts with a count of zero. | |
| // | |
| // Configuration | |
| // 28. A LaunchCredential lives long enough to carry one launch — a page load and a | |
| // retry or two — and no longer. | |
| // 29. A Session lives long enough to span the gaps between a clinician's actions. | |
| // 30. The wrong-PIN limit is small enough to make guessing hopeless, large enough | |
| // to forgive mistyping. | |
| // | |
| // State — where Session state lives; chosen so that the Server keeps none of it. | |
| // 31. The Server holds no Session state between requests: what a User is prescribing | |
| // lives in the Client, and a Session's identity and standing live in its | |
| // SessionRecord in the Database. Two Users' work cannot meet in the Server, | |
| // because the Server holds neither. | |
| // 32. The Server takes the User and the Patient of a request from the SessionRecord, | |
| // never from the request's payload. | |
| // 33. The TreatmentPlan a Session opened with travels as a token the Server issued at the | |
| // opening — bound to the Session, the Patient and the TreatmentPlan — returned by the | |
| // Client with every create and verified then (Rules 19, 20). The Client cannot | |
| // make one. | |
| // 34. A choice to create anyway (Rule 21) travels the same way: a token issued with | |
| // the notice, naming the Unsigned TreatmentPlans it disclosed, honoured for those and | |
| // for nothing newer. | |
| // 35. The stamps of Rule 14 are computed by the Server against the base TreatmentPlan; a | |
| // stamp arriving from the Client is never accepted. | |
| // 36. The Rule 20 check and the append are one act at the Database: a TreatmentPlan lands | |
| // only if the head the check saw is still the head. More than one Server may | |
| // run; the Database arbitrates. | |
| // | |
| // ── Guarantees ───────────────────────────────────────────────────────────────── | |
| // What the Rules add up to. Derived, not asserted. Checked at the end of the run. | |
| // | |
| // 1. One constant. A PatientRecord is a sequence of TreatmentPlans in which the | |
| // PatientId is the only constant. | |
| // 2. One version. At any moment exactly one TreatmentPlan is the visible version and the | |
| // only starting point for updating it. | |
| // 3. Carts and one checkout. The cart is private — it lives in the User's own Client | |
| // and the Server keeps none of it (Rule 31) — signing is the only checkout, and | |
| // the first User to sign wins the version (Rule 36). | |
| // 4. Audit. A Signed TreatmentPlan carries the User who signed it, every OrderContext | |
| // carries the User whose Session last changed it, and the record is append-only. | |
| // | |
| // ── Open Questions ───────────────────────────────────────────────────────────── | |
| // 1. Mail deliverability. Rule 26's guarantee holds only if the UserRegistry | |
| // address is current and the MailService delivers. Neither can be checked here. | |
| // 2. Unsigned TreatmentPlans in the PatientDataPlatform. Rules 17 and 18 bind GenPRES, | |
| // but the PatientDataPlatform holds a copy of the Database readable by the | |
| // Workstation and other applications. Unsigned work, closed to other Users | |
| // inside GenPRES, may thus be readable outside it. | |
| // 3. Payload. Under Rule 31 the whole of the work travels with every computing | |
| // request and every create. Whether that is acceptable is a measurement, not a | |
| // judgement. A bad number would force a server-side cache of the work, which must | |
| // then be built as an optimisation the Rules never depend on, losable without | |
| // breaking anything. This model shows the pure design: `ServerState` holds | |
| // counters, the in-flight table and nothing else, and that is asserted after | |
| // every scenario step. | |
| // | |
| // ── Where this model differs from the repository ─────────────────────────────── | |
| // The repository's GenPRES Server is already stateless between requests. That is no | |
| // longer a divergence from the document: it is the specified design (Rule 31), | |
| // extended here with the Session identity and standing that the SessionRecord holds | |
| // and the tokens of Rules 33 and 34 that make a client-held cart safe to trust. | |
| // | |
| // | |
| // The rest of the file is in three parts: | |
| // 1. types — the vocabulary: identities, concepts, messages, actor state | |
| // 2. modules — the edge table, the Record rules, the tokens, and the reducer | |
| // 3. scenarios — the harness, UC-1 .. UC-13, and the derived assertions | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // 1. TYPES | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // ───────────────────────────── identities ───────────────────────────── | |
| type UserId = UserId of string // stable key: what audit keys on | |
| type LoginName = LoginName of string // unique today, but renameable | |
| type MailAddress = MailAddress of string // from the UserRegistry (Rule 26) | |
| type PatientId = PatientId of string | |
| type BrowserId = BrowserId of int | |
| type LaunchCredential = LaunchCredential of string // Concept 4: opaque to GenPRES | |
| type LaunchNo = LaunchNo of int // readable handle, safe to log | |
| type SessionId = SessionId of string // Rule 11: bearer, never in a URL | |
| type SessionNo = SessionNo of int // traces and ui only, never a key | |
| type TreatmentPlanId = TreatmentPlanId of string | |
| type TreatmentPlanNo = TreatmentPlanNo of int // ordering within one PatientRecord | |
| type OrderContextId = OrderContextId of string // Concept 10: persists across plans | |
| type AttemptId = AttemptId of int // correlates one launch across ports | |
| /// Rule 31. Correlates the several Database legs of ONE request. Created when the | |
| /// request arrives, dropped with its reply — never carried from one request to the | |
| /// next, which is the whole of what makes the Server stateless. | |
| type RequestId = RequestId of int | |
| type Pin = Pin of string // Concept 7. Never leaves GenPRES. | |
| /// Roles. The UserRegistry decides which. | |
| type Role = | |
| | Prescriber | |
| | Reader | |
| /// The ten Actors of the document, plus Environment — which is not a use case actor | |
| /// but the world they run in: the clock, and starting and stopping infrastructure. | |
| type ActorId = | |
| | User // Actor 7 | |
| | MainEhrWorkstation // Actor 1 [given] | |
| | MainEhrLaunchScript // Actor 2 [ours] | |
| | GenPresClient of BrowserId // Actor 3 [ours] | |
| | GenPresServer // Actor 4 [ours] | |
| | GenPresDatabase // Actor 5 [ours] | |
| | PatientDataPlatform // Actor 6 [given] | |
| | Broker // Actor 8 [ours] | |
| | UserRegistry // Actor 9 [ours] | |
| | MailService // Actor 10 [given] | |
| | Environment | |
| // ───────────────────────────── the concepts ───────────────────────────── | |
| /// Concept 1. Identification and Role — nothing else. The Role is the registry's | |
| /// answer (Rule 5), never the launch's. | |
| type UserContext = | |
| { | |
| UserId : UserId | |
| Login : LoginName | |
| Role : Role | |
| } | |
| /// Patient Data relevant for GenPRES. Opaque here: this is a model of the protocol, | |
| /// not of the clinical content. | |
| type PatientData = PatientData of string | |
| /// Concept 2. Only a launch can supply the identification; the User can supply the | |
| /// data by hand. Read from the PatientDataPlatform once, at the launch, and not | |
| /// refreshed while the Session lives. | |
| type PatientContext = | |
| { | |
| Patient : PatientId option | |
| Data : PatientData option | |
| } | |
| /// Concept 3. The whole of what crosses the Broker port, and only as trustworthy as | |
| /// whatever wrote it: a login to look up, and a Patient if one was active. No | |
| /// verified identity, no Role. | |
| type LaunchAssertion = | |
| { | |
| Login : LoginName | |
| Patient : PatientId option | |
| } | |
| /// Concept 7. Carries no Role and no identity of its own. A UserCredential without a | |
| /// PIN cannot sign. | |
| type UserCredential = | |
| { | |
| Login : LoginName | |
| Pin : Pin option | |
| AttemptCount : int // Rule 27: counts across Sessions | |
| } | |
| /// Concept 10. It has an identity that persists across TreatmentPlans, a PatientId it | |
| /// belongs to, whatever the User is putting into it, and the stamp: the UserContext | |
| /// of the User whose Session last changed it (Rule 14). | |
| /// | |
| /// Abstracted here: the document's OrderContext is a PatientContext together with the | |
| /// OrderScenarios under consideration (Concepts 10, 11). None of the clinical content | |
| /// is modelled — `Content` stands in for the whole of it, opaque like PatientData. | |
| /// What the Rules turn on is only that it has an identity, names a Patient | |
| /// (Guarantee 1, Rule 32), can be compared with the base to tell changed from | |
| /// unchanged (Rule 35), and carries a stamp. | |
| /// | |
| /// The cart is client-held (Rule 31), so all four fields arrive from the Client with | |
| /// every request — and the Server trusts exactly two of them: `Id` and `Content`. | |
| /// `Patient` is checked against the SessionRecord and `Stamp` is recomputed. | |
| type OrderContext = | |
| { | |
| Id : OrderContextId | |
| Patient : PatientId option | |
| Content : string | |
| Stamp : UserContext option | |
| } | |
| /// Concept 13. Signed or Unsigned (Rule 15), by exactly one User (Rule 14), over the | |
| /// TreatmentPlan it was created from — its base — if any. | |
| type TreatmentPlan = | |
| { | |
| Id : TreatmentPlanId | |
| No : TreatmentPlanNo | |
| Patient : PatientId | |
| By : UserContext | |
| Base : TreatmentPlanId option | |
| Orders : OrderContext list | |
| Signed : bool | |
| At : int | |
| } | |
| /// What the Server hands the Database to append (Rule 36). It has no Id and no No: | |
| /// those are the Database's to mint, because the Database is what serialises the | |
| /// appends now that more than one Server may run. | |
| type TreatmentPlanDraft = | |
| { | |
| Patient : PatientId | |
| By : UserContext | |
| Base : TreatmentPlanId option | |
| Orders : OrderContext list | |
| Signed : bool | |
| } | |
| /// Concept 12. Append-only. Newest first, so the Record rules are `List.tryFind`. | |
| /// The PatientId is the one thing no TreatmentPlan may change (Guarantee 1). | |
| type PatientRecord = | |
| { | |
| Patient : PatientId | |
| Plans : TreatmentPlan list | |
| } | |
| // ───────────────────────────── the tokens ───────────────────────────── | |
| /// Rule 33. The TreatmentPlan a Session opened with, as something the Client can hold | |
| /// and hand back but cannot make. Minted at the opening of the Session and re-minted | |
| /// whenever the baseline moves — an open (Rule 17) or a create — because Rules 20 and | |
| /// 21 are both measured from it. | |
| /// | |
| /// `Mac` stands in for an HMAC over the other three fields and a secret only the | |
| /// Server knows. Verification is recomputing it and comparing. | |
| type OpenedToken = | |
| { | |
| Sid : SessionId | |
| Patient : PatientId option | |
| Plan : TreatmentPlanId option | |
| Mac : string | |
| } | |
| /// Rule 34. The User's choice to create anyway, as something that names exactly what | |
| /// they were shown: honoured for those Unsigned TreatmentPlans and for nothing newer. | |
| type NoticeToken = | |
| { | |
| Sid : SessionId | |
| Disclosed : TreatmentPlanId list | |
| Mac : string | |
| } | |
| // ───────────────────────────── session state ───────────────────────────── | |
| /// Rule 9, exactly: the four ways a Session ends, and no others. A Server restart is | |
| /// not among them — the Server holds no Session state to lose (Rule 31). | |
| type EndMark = | |
| | ClosedByUser | |
| | Idle | |
| | Superseded | |
| | WrongPinLimit | |
| /// Two states. `OpenOrGone` also covers "the Client has gone quiet and the Server | |
| /// cannot yet tell" — Rule 9 says a vanished browser is indistinguishable from a | |
| /// silent one, so there is nothing finer to record. | |
| type SessionState = | |
| | OpenOrGone | |
| | Ended of mark: EndMark * at: int | |
| /// Rule 10, as a state rather than a timestamp. `int option` could not tell "no | |
| /// notice is owed" apart from "one is owed and not yet given": a Session the User | |
| /// closed themselves is owed nothing at all (Rule 10 speaks only of endings other | |
| /// than by the User), while one that idled out is. Orthogonal to *how* a Session | |
| /// ended — being told is not a way for a Session to end. | |
| /// | |
| /// "Notice" is the document's own noun for this — "the notice comes with the new | |
| /// launch", "the notice is not repeated", "no notice at the next launch", "a harmless | |
| /// notice". Not `Notification`: in the document that word is the heading of the Rules | |
| /// group holding Rule 21 — the notice that another User's Unsigned work exists, which | |
| /// is a different thing entirely and is carried here by `UnsignedWorkNotice`. | |
| type SessionNotice = | |
| /// The Session is open, or the User closed it themselves. Nothing is owed. | |
| | NotOwed | |
| /// It ended in a way the User has not been told about, and will be at the next | |
| /// opportunity: a Client still holding that SessionId, or the User's next launch. | |
| | Owed | |
| /// Told, and once only. | |
| | Told of at: int | |
| /// Concept 9 — the record of a Session, and now the whole of what GenPRES remembers | |
| /// of one between requests (Rule 31). Lives in the Database, is kept after the | |
| /// Session ends, and the Server is its only writer. | |
| /// | |
| /// It carries the UserContext, not merely the UserId: the Role a Session runs under | |
| /// is the one its launch established (UC-13 ext 1a), and Rule 32 takes the User of a | |
| /// request from here rather than from the payload. The mail address rides along for | |
| /// the same reason — Rule 26 has to reach the User with no Session in memory to ask. | |
| type SessionRecord = | |
| { | |
| Id : SessionId | |
| No : SessionNo | |
| /// None: the Session was anonymous (Rule 13). | |
| User : UserContext option | |
| Mail : MailAddress option | |
| Patient : PatientId option | |
| Launch : LaunchNo option // None: no launch — an anonymous open | |
| OpenedAt : int | |
| /// Rule 8: every request from the Client refreshes this. The idle clock lives | |
| /// here because there is nowhere else for it to live. | |
| LastSeen : int | |
| State : SessionState | |
| /// Rule 10. Set by `endWith`, so the obligation is created by the same act | |
| /// that creates the ending and cannot drift from it. | |
| Notice : SessionNotice | |
| } | |
| // ───────────────────────────── failures ───────────────────────────── | |
| type LaunchFailure = | |
| | NotFound | |
| | CredentialExpired // Rule 3 | |
| | AlreadyRedeemed // Rule 2 | |
| | BrokerUnreachable // UC-1 ext 8b | |
| type RegistryFailure = | |
| | NoRole // the registry knows the login, and says no | |
| | RegistryUnreachable // the registry cannot say | |
| /// Which exchange a Database answer belongs to: a launch in flight, one request in | |
| /// flight, or the idle sweep. Nothing outlives its exchange. | |
| type DbTag = | |
| | ForLaunch of AttemptId | |
| | ForRequest of RequestId | |
| | ForSweep | |
| // ───────────────────────────── messages ───────────────────────────── | |
| /// What travels from the Client to the Server inside a Session. Every one of these | |
| /// arrives as a `SessionRequest`, so Rule 8's idle-clock refresh has exactly one home | |
| /// — and every one of them is answered out of its own payload plus the SessionRecord, | |
| /// with nothing kept afterwards (Rule 31). | |
| type SessionCmd = | |
| /// Concept 15. The Client has already changed its own cart; this sends the whole | |
| /// of it for computing. The answer comes back from the payload, and the Server | |
| /// keeps none of it. | |
| | Compute of OrderContext list | |
| /// Concept 14. Saving and Signing are one act. `None` saves — the TreatmentPlan is | |
| /// Unsigned. `Some pin` signs — Signed, if the PIN verifies (Rule 22). The whole | |
| /// cart travels, with the opened-with token (Rule 33) and, where the User is | |
| /// answering a Rule 21 notice, the token that came with it (Rule 34). | |
| | CreateTreatmentPlan of | |
| Pin option * OrderContext list * PatientData option * OpenedToken * NoticeToken option | |
| | OpenTreatmentPlan of TreatmentPlanId // Rules 17, 18 | |
| | ResetPin // UC-7 | |
| | CloseSession // Rule 9 | |
| /// What the User does at the Client. Distinct from `SessionCmd`: some of these are | |
| /// purely local (the cart is the Client's), and every one that does reach the Server | |
| /// carries the cart with it. | |
| /// | |
| /// There is no `Proceed` and no `HoldOff`. Under Rule 34 proceeding is re-sending the | |
| /// create with the token the notice came with, and holding off is not sending it. | |
| type UserAct = | |
| | Prescribes of OrderContextId // Concept 15: add or change, in the Client | |
| | EntersPatientData of PatientData // Concept 2: the User supplies it by hand | |
| | Saves // Concept 14, Unsigned | |
| | Signs of Pin // Concept 14, Signed if it verifies | |
| | OpensTreatmentPlan of TreatmentPlanId // Rules 17, 18 | |
| | AsksPinReset // UC-7 | |
| | ClosesSession // Rule 9 | |
| /// UC-9 step 5. The cart survived the Session because it was never in the Server | |
| /// (Rule 31); the User carries it into the next one as fresh prescribing. It | |
| /// survives exactly as far as the browser does. | |
| | CarriesOverFrom of BrowserId | |
| type Msg = | |
| // ── Environment: the clock and the infrastructure ── | |
| | Tick | |
| | Start of ActorId | |
| | Stop of ActorId | |
| // ── U1. User <-> MainEHR Workstation ── | |
| | LogIn of LoginName | |
| | SelectPatient of PatientId | |
| | ClearPatient | |
| // ── U2. User <-> MainEHR LaunchScript ── | |
| | TriggerLaunch | |
| /// UC-1 ext 3a. The one launch failure the EHR side can report: the Broker edge is | |
| /// request-response, and the LaunchScript has not yet exited. Its reporting ends | |
| /// with its own acts — after the launch it learns nothing (Consequence 1). | |
| | LaunchError of string | |
| // ── C3. MainEHR LaunchScript <-> Broker. No Role: the launch carries no rights. ── | |
| | PrepareLaunch of LoginName * PatientId option | |
| | LaunchPrepared of LaunchCredential | |
| | LaunchNotPrepared | |
| // ── C4. MainEHR LaunchScript => GenPRES Client. One-way: Consequence 1. ── | |
| | OpenUrl of LaunchCredential | |
| // ── U3. User <-> GenPRES Client ── | |
| | Refresh // re-present whatever is in the address bar | |
| | OpenDirectly // UC-8: no launch, no credential | |
| | AcceptAnonymousOffer // Rule 6, UC-1 ext 9a | |
| | ChoosePin of Pin // UC-2 step 3, mid-launch | |
| | Act of UserAct | |
| | CloseBrowser // UC-12 ext 2a: nothing reaches the Server | |
| // ── C5. GenPRES Client -> GenPRES Server ── | |
| | RedeemLaunch of LaunchCredential | |
| | OpenAnonymous // Rule 13 | |
| | SupplyPin of AttemptId * Pin // UC-2: the launch is suspended on a human | |
| | SessionRequest of SessionId * SessionCmd | |
| // ── C6. GenPRES Server <-> Broker ── | |
| | ResolveLaunch of AttemptId * LaunchCredential | |
| | LaunchResolved of AttemptId * LaunchNo * LaunchAssertion | |
| | LaunchRejected of AttemptId * LaunchNo option * LaunchFailure | |
| // ── C7. GenPRES Server <-> UserRegistry. The credential never reaches here. ── | |
| | ResolveUser of AttemptId * LoginName | |
| | UserResolved of AttemptId * UserContext * MailAddress | |
| | UserUnresolved of AttemptId * RegistryFailure | |
| // ── C8. GenPRES Server <-> PatientDataPlatform ── | |
| | ReadPatientData of AttemptId * PatientId | |
| | PatientDataRead of AttemptId * PatientData | |
| | PatientDataUnavailable of AttemptId | |
| // ── C9. GenPRES Server <-> GenPRES Database. The Server is its only writer. ── | |
| | ReadCredential of DbTag * LoginName | |
| | CredentialRead of DbTag * UserCredential option | |
| | WriteCredential of DbTag * UserCredential | |
| | CredentialWritten of DbTag * UserCredential | |
| | RemovePin of DbTag * LoginName | |
| | PinRemoved of DbTag * UserCredential | |
| | ReadRecord of DbTag * PatientId | |
| | RecordRead of DbTag * PatientRecord | |
| /// Rule 36. The Rule 20 check and the append are one act at the Database: the | |
| /// Server sends the head its check saw, and the TreatmentPlan lands only if that is | |
| /// still the head. The draft has no Id: the Database mints it, because the | |
| /// Database is what orders the record now. | |
| | AppendIf of DbTag * TreatmentPlanId option * TreatmentPlanDraft | |
| | TreatmentPlanAppended of DbTag * TreatmentPlan | |
| | AppendRefused of DbTag * TreatmentPlanId | |
| | WriteSessionRecord of SessionRecord | |
| | ReadSessionRecord of DbTag * SessionId | |
| | SessionRecordRead of DbTag * SessionRecord option | |
| | ReadSessionRecords of DbTag | |
| | SessionRecordsRead of DbTag * SessionRecord list | |
| // ── C10. GenPRES Server -> MailService ── | |
| | SendMail of MailAddress * string | |
| // ── GenPRES Server -> GenPRES Client (replies only: Consequence 6) ── | |
| | SessionOpened of | |
| SessionId * SessionNo * UserContext option * PatientContext * OrderContext list * OpenedToken | |
| | PinRequired of AttemptId // UC-2: choose one, and nothing else is offered | |
| | LaunchRefused // carries no reason, deliberately | |
| | NotAuthorised // the registry says no; no reason either | |
| | AuthorityUnavailable // the registry cannot say | |
| | ServerUnreachable | |
| /// Rule 10's one telling. The mark is what ended it. | |
| | SessionEnded of EndMark option // None: the Server has no such record | |
| /// The request is refused because the Session is not open — but the User has | |
| /// already been told why, and Rule 10 says never twice. | |
| | SessionRefused | |
| | PriorSessionNotice of (SessionNo * SessionState) list // Rule 10 | |
| /// Rule 31. The answer to `Compute`, computed from the payload and kept nowhere. | |
| | Computed of OrderContext list | |
| | CreateBlocked of TreatmentPlanId // Rules 20, 36 | |
| /// Rule 21: whose work, not its contents. Rule 34: and the token that names what | |
| /// was disclosed, which is what a choice to create anyway must return. | |
| | UnsignedWorkNotice of UserContext * NoticeToken | |
| /// Rules 32, 33. The payload contradicted the SessionRecord, or the token did not | |
| /// verify. Carries a reason for the trace; the Client shows nothing but a refusal. | |
| | CreateRefused of string | |
| | TreatmentPlanCreated of TreatmentPlanId * bool * OpenedToken | |
| | TreatmentPlanOpened of TreatmentPlanId * OrderContext list * OpenedToken | |
| | PinRejected of int // Rule 27: attempts left | |
| | NoTreatmentPlanHere // Rule 12 | |
| | NotPermitted // Roles: a Reader never creates a TreatmentPlan | |
| | PinReset // UC-7 step 2 | |
| // ── any actor -> Environment (standing in for the audit log) ── | |
| /// An envelope no edge permits. Not merely dropped: a forged or misrouted | |
| /// envelope is exactly the event worth alerting on. | |
| | Refused of Envelope | |
| /// Rule 26's other half — "records the change". The mail is the User-facing | |
| /// notice; this is the record. | |
| | Noted of string | |
| and Envelope = | |
| { | |
| From : ActorId | |
| To : ActorId | |
| Msg : Msg | |
| } | |
| // ───────────────────────────── actor state ───────────────────────────── | |
| /// The Broker's own record. It has a lifecycle — issued when, spent or not — that no | |
| /// message carries. GenPRES never sees it, only the LaunchAssertion projected from | |
| /// it, which deliberately drops the credential and the spent flag. | |
| type LaunchRecord = | |
| { | |
| Credential : LaunchCredential | |
| No : LaunchNo | |
| Login : LoginName | |
| Patient : PatientId option | |
| IssuedAt : int | |
| Redeemed : bool | |
| } | |
| /// Actor 1 [given]. Invariant 1: at most one active Patient at a time. | |
| type WorkstationState = | |
| { | |
| ActiveUser : LoginName option | |
| ActivePatient : PatientId option | |
| NextTab : int | |
| } | |
| /// Actor 8. Under SMART on FHIR this would be the EHR's authorisation server. | |
| type BrokerState = | |
| { | |
| Launches : Map<LaunchCredential, LaunchRecord> | |
| NextNo : int | |
| Up : bool | |
| } | |
| /// Actor 9. Says who a login belongs to, what that person may do, and how to reach | |
| /// them by mail. The only source of a Role (Rule 5). | |
| type RegistryState = | |
| { | |
| Users : Map<LoginName, UserContext * MailAddress> | |
| Up : bool | |
| } | |
| /// Actor 6 [given]. Read-only, and read once per launch (Concept 2). | |
| type PlatformState = | |
| { | |
| Data : Map<PatientId, PatientData> | |
| Up : bool | |
| } | |
| /// Actor 5. The Server is its only writer. Open Question 2 concerns a copy of this | |
| /// held by the PatientDataPlatform. | |
| /// | |
| /// `NextPlan` lives here, not in the Server: Rule 36 makes the Database the party | |
| /// that decides whether an append lands, so it is also the party that can hand out an | |
| /// ordering. More than one Server may run; only one Database does. | |
| type DatabaseState = | |
| { | |
| Sessions : SessionRecord list // Concept 9 | |
| Records : Map<PatientId, PatientRecord> // Concept 12 | |
| Credentials : Map<LoginName, UserCredential> // Concept 7 | |
| NextPlan : int | |
| } | |
| /// One launch attempt, mid-flight. The stages follow UC-1's trace, and the order is | |
| /// the document's, not a convenience: | |
| /// Rule 24 the PIN is offered only after the registry has recognised the login | |
| /// Rule 25 a Reader skips the credential stage entirely | |
| /// ext 1a no Patient: the platform and record stages are skipped | |
| /// ext 11a the platform being unreachable is not a failure | |
| /// The credential is handed to the Broker and not kept: after ResolveLaunch, GenPRES | |
| /// holds only the launch number, which is safe to log and safe to store. | |
| type LaunchCtx = | |
| { | |
| Client : ActorId | |
| Launch : LaunchNo | |
| Assertion : LaunchAssertion | |
| } | |
| /// A launch in flight, with the tick it reached this stage. The tick is what makes an | |
| /// abandoned launch collectable: everything here is waiting on a round trip that | |
| /// should return promptly, except AwaitingPinChoice, which waits on a human (UC-2). | |
| /// | |
| /// This table has always had the shape Rule 31 asks for: per-attempt, and nothing | |
| /// retained once the reply goes out. It is not Session state. | |
| type PendingLaunch = | |
| | AwaitingAssertion of client: ActorId | |
| | AwaitingUser of LaunchCtx | |
| | AwaitingCredential of LaunchCtx * UserContext * MailAddress | |
| /// UC-2. The launch is suspended on a human and may stay here indefinitely. | |
| | AwaitingPinChoice of LaunchCtx * UserContext * MailAddress | |
| | AwaitingPinWritten of LaunchCtx * UserContext * MailAddress | |
| | AwaitingPatientData of LaunchCtx * UserContext * MailAddress | |
| | AwaitingRecord of LaunchCtx * UserContext * MailAddress * PatientContext | |
| /// Rule 7 needs the User's other SessionRecords, and the Server no longer mirrors | |
| /// them — so closing the rest is a Database leg like any other. | |
| | AwaitingPriors of LaunchCtx * UserContext * MailAddress * PatientContext * TreatmentPlan option | |
| /// One entry in the Server's launch table. | |
| type PendingEntry = | |
| { | |
| Stage : PendingLaunch | |
| Since : int | |
| } | |
| /// How far one in-Session request has got through its Database legs. Every stage | |
| /// carries what the earlier legs returned, because there is nowhere else to keep it: | |
| /// the Server holds nothing between requests (Rule 31), and this table is emptied by | |
| /// the reply. | |
| type RequestStage = | |
| /// Rule 32: before anything else, who and which Patient this Session is. | |
| | AwaitingSessionRecord | |
| /// Rules 17 to 21 are decided against the PatientRecord. | |
| | AwaitingPatientRecord of SessionRecord | |
| /// Rule 22, carrying the record, the head the Rule 20 check saw, and the base. | |
| | AwaitingUserCredential of SessionRecord * PatientRecord * TreatmentPlan option | |
| /// Rule 36: the Database is deciding whether the append lands. | |
| | AwaitingAppend of SessionRecord | |
| /// UC-7 step 2. Rule 26 mails the address on the record, so the record is held. | |
| | AwaitingPinRemoved of SessionRecord | |
| /// Rule 31 made visible: one entry per request in flight, created when the request | |
| /// arrives and removed with its reply. Nothing here survives the answer. | |
| type RequestCtx = | |
| { | |
| Sid : SessionId | |
| Client : ActorId | |
| Cmd : SessionCmd | |
| Stage : RequestStage | |
| } | |
| /// Actor 4. Counters, what is in flight, and whether it is up — and nothing else. | |
| /// That is Rule 31 as a type: there is no field a Session could live in. | |
| type ServerState = | |
| { | |
| /// One entry per in-Session request, gone with the reply. | |
| InFlight : Map<RequestId, RequestCtx> | |
| /// One entry per launch attempt, gone with the launch. | |
| Pending : Map<AttemptId, PendingEntry> | |
| /// Separate id spaces, so separate counters. All monotonic — an id is never | |
| /// reissued. The TreatmentPlan counter is not here: Rule 36 moved it to the | |
| /// Database, which is the party that orders a PatientRecord. | |
| NextAttempt : int | |
| NextRequest : int | |
| NextSessionId : int | |
| Up : bool | |
| } | |
| /// Actor 3. Carries no identity of its own: a User is only known through a launch. | |
| /// It does carry the work, though — the cart is here and nowhere else (Rule 31), so | |
| /// this is where a Session's contents survive a Server restart, and where they die | |
| /// when the browser does. | |
| type BrowserState = | |
| { | |
| /// Consequence 4: the credential is in the address bar, and stays there. | |
| UrlCredential : LaunchCredential option | |
| /// Rule 11: a bearer credential, held here and sent in the request. | |
| Sid : SessionId option | |
| /// What the Server said this Session's User and Patient are (Concepts 1, 2). | |
| /// Shown to the User; never sent back as an assertion — Rule 32 takes both | |
| /// from the SessionRecord. | |
| User : UserContext option | |
| Patient : PatientId option | |
| /// The cart (Concept 15). Travels with every request and lives nowhere else. | |
| Working : OrderContext list | |
| Data : PatientData option | |
| /// Rule 33. Issued by the Server, returned with every create. | |
| Opened : OpenedToken option | |
| /// Rule 34. Kept from the last UnsignedWorkNotice, returned to create anyway. | |
| Notice : NoticeToken option | |
| /// The attempt this Client was asked to choose a PIN for (UC-2). | |
| AwaitingPin : AttemptId option | |
| /// Rule 6: whether a fresh anonymous open is on offer. | |
| AnonymousOffer : bool | |
| /// Whatever the Client is currently putting in front of the User. Not only | |
| /// notices: a question (Rule 21), a prompt (UC-2), an unavailability. The | |
| /// Session-ended notice of Rule 10 is one of the things that can land here. | |
| Showing : string option | |
| Closed : bool | |
| } | |
| /// The world the participants run in, not a participant's own state. It disappears | |
| /// in production: real time arrives as Tick. | |
| type EnvState = | |
| { | |
| Now : int | |
| } | |
| /// Every field is state owned by exactly one participant — Clients per key — except | |
| /// Env, which is the world they all run in. Nothing is shared, so nothing in the | |
| /// model can depend on a memory read across what will be a process boundary. The | |
| /// rule is a convention over the reducer, not something the type enforces: only the | |
| /// branch bodies decide who reads what. | |
| type Hospital = | |
| { | |
| Workstation : WorkstationState | |
| Broker : BrokerState | |
| Registry : RegistryState | |
| Platform : PlatformState | |
| Database : DatabaseState | |
| GenPres : ServerState | |
| Clients : Map<BrowserId, BrowserState> | |
| Mail : (MailAddress * string) list // what the MailService sent | |
| Audit : string list // Rule 26's "records the change" | |
| Env : EnvState | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // 2. MODULES | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // ───────────────────────────── configuration ───────────────────────────── | |
| // Rules 28, 29, 30. Owned by the actor named in the comment, not by the model. | |
| // The unit is one handled message: `update` advances the clock on every move, so a | |
| // lifetime has to be read against the length of a cascade, not a count of Ticks — a | |
| // launch from trigger to open Session is some twenty-odd of them. | |
| /// Rule 28. Long enough to carry one launch — a page load and a retry or two. | |
| /// The Broker owns this; GenPRES never sees it. | |
| let credentialTtl = 20 | |
| /// Rule 29. Long enough to span the gaps between a clinician's actions. | |
| let sessionTtl = 25 | |
| /// Rule 30. Small enough to make guessing hopeless, large enough to forgive | |
| /// mistyping. Owned by GenPRES. | |
| let wrongPinLimit = 3 | |
| // ───────────────────────────── the edge table ───────────────────────────── | |
| /// The document's notation, as data. | |
| type EdgeKind = | |
| | Request // X -> Y initiate, and receive Y's reply | |
| | Launch // X => Y one-way: no response, no error path | |
| | Interact // X <-> Y read what Y shows, and act on it | |
| module Edges = | |
| /// The document's Constraints section, verbatim. Anything not here cannot | |
| /// exchange data at all, and edges do not compose. | |
| let table : (ActorId * EdgeKind * ActorId) list = | |
| [ | |
| // User Interaction | |
| User, Interact, MainEhrWorkstation // U1 | |
| User, Interact, MainEhrLaunchScript // U2 | |
| User, Interact, GenPresClient(BrowserId 0) // U3 | |
| // Communication | |
| MainEhrWorkstation, Request, UserRegistry // C1 | |
| MainEhrWorkstation, Request, PatientDataPlatform // C2 | |
| MainEhrLaunchScript, Request, Broker // C3 | |
| MainEhrLaunchScript, Launch, GenPresClient(BrowserId 0) // C4 | |
| GenPresClient(BrowserId 0), Request, GenPresServer // C5 | |
| GenPresServer, Request, Broker // C6 | |
| GenPresServer, Request, UserRegistry // C7 | |
| GenPresServer, Request, PatientDataPlatform // C8 | |
| GenPresServer, Request, GenPresDatabase // C9 | |
| GenPresServer, Request, MailService // C10 | |
| // U2 is `<->` and not `=>`: the LaunchScript reports the one failure it | |
| // can see — the Broker exchange — before it exits (UC-1 ext 3a). What | |
| // bounds it is not the edge but its own lifetime: it exits at the launch, | |
| // so nothing later ever comes from it (Consequence 1). The edge that | |
| // carries Consequence 1 is C4, which stays `=>`. | |
| ] | |
| /// Clients differ by BrowserId; edges do not. | |
| let private tag = | |
| function | |
| | GenPresClient _ -> GenPresClient(BrowserId 0) | |
| | a -> a | |
| let private has kind a b = | |
| table |> List.exists (fun (x, k, y) -> k = kind && x = tag a && y = tag b) | |
| /// May `from` put this envelope on the wire to `to_`? | |
| /// | |
| /// A Request edge permits both the initiation and the reply that comes back on | |
| /// the same connection. A Launch edge permits the one direction only — which is | |
| /// what makes Consequence 1 true by construction: nothing can ever be sent back | |
| /// to the LaunchScript, because no edge carries it. | |
| let permits from to_ = | |
| // Environment is not a use case actor: it is the clock and the power switch, | |
| // and every actor may write to the audit log. | |
| if from = Environment || to_ = Environment then true | |
| elif has Interact from to_ || has Interact to_ from then true | |
| elif has Launch from to_ then true | |
| elif has Request from to_ then true | |
| elif has Request to_ from then true // the reply leg | |
| else false | |
| // ───────────────────────────── the record rules ───────────────────────────── | |
| /// Rules 16 to 21. Small total functions over a TreatmentPlan list held newest first, so | |
| /// "most recent" is `List.tryFind` and "newer than" is a comparison of TreatmentPlanNo. | |
| module PatientRecord = | |
| let empty patient = { Patient = patient; Plans = [] } | |
| let private no (s: TreatmentPlan) = let (TreatmentPlanNo n) = s.No in n | |
| /// Rule 36's half of the check: what the Server saw as the head when it decided. | |
| let head (r: PatientRecord) = r.Plans |> List.tryHead |> Option.map _.Id | |
| /// "newer than the TreatmentPlan the User opened with" — and where the User opened | |
| /// with nothing, any TreatmentPlan at all counts as newer (Rule 21's parenthesis). | |
| let private newerThan (openedWith: TreatmentPlanId option) (s: TreatmentPlan) (r: PatientRecord) = | |
| match openedWith with | |
| | None -> true | |
| | Some id -> | |
| match r.Plans |> List.tryFind (fun x -> x.Id = id) with | |
| | Some baseline -> no s > no baseline | |
| | None -> true // the baseline is not in this record at all | |
| /// Rule 16. The only TreatmentPlan that counts clinically. | |
| let latestSigned (r: PatientRecord) = | |
| r.Plans |> List.tryFind _.Signed | |
| /// Rule 19. Where neither exists, the User works from nothing. A Reader never | |
| /// creates a TreatmentPlan, so this can only ever hand them the latest Signed one. | |
| let startsFrom (u: UserId) (r: PatientRecord) = | |
| r.Plans | |
| |> List.tryFind (fun s -> s.Signed || s.By.UserId = u) | |
| /// Rules 17 and 18. The most recent Signed TreatmentPlan is the only one another User | |
| /// can open — older TreatmentPlans are record, not views — and an Unsigned TreatmentPlan | |
| /// opens only for the User who created it. | |
| let mayOpen (u: UserId) (id: TreatmentPlanId) (r: PatientRecord) = | |
| match r.Plans |> List.tryFind (fun s -> s.Id = id) with | |
| | None -> None | |
| | Some s when s.Signed -> | |
| if latestSigned r |> Option.map _.Id = Some id then Some s else None | |
| | Some s -> if s.By.UserId = u then Some s else None | |
| /// Rule 20. A Signed TreatmentPlan newer than the one the User opened with blocks the | |
| /// create — and opening that Signed TreatmentPlan lifts the block, because it becomes | |
| /// the one the Session opened with. | |
| let blocking (openedWith: TreatmentPlanId option) (r: PatientRecord) = | |
| latestSigned r |> Option.filter (fun s -> newerThan openedWith s r) | |
| /// Rule 21, and Rule 34's half of it: *every* Unsigned TreatmentPlan of another User | |
| /// newer than the one opened with, because the notice token names what was | |
| /// disclosed and is honoured for nothing newer. Newest first. | |
| let unsignedElsewhere (u: UserId) (openedWith: TreatmentPlanId option) (r: PatientRecord) = | |
| r.Plans | |
| |> List.filter (fun s -> | |
| not s.Signed && s.By.UserId <> u && newerThan openedWith s r) | |
| /// Concept 12: append-only. The newest TreatmentPlan goes on the front, and no | |
| /// existing one is ever touched. | |
| let append (s: TreatmentPlan) (r: PatientRecord) = | |
| { r with Plans = s :: r.Plans } | |
| // ───────────────────────────── the credential ───────────────────────────── | |
| /// Concept 7 and Rule 27. | |
| module UserCredential = | |
| let fresh login = { Login = login; Pin = None; AttemptCount = 0 } | |
| /// Rule 26: a newly set PIN starts with a count of zero. | |
| let setPin pin c = { c with Pin = Some pin; AttemptCount = 0 } | |
| /// UC-7 step 2: the UserCredential stays, now without a PIN. Concept 7: one | |
| /// without a PIN cannot sign. | |
| let removePin c = { c with Pin = None; AttemptCount = 0 } | |
| /// Rule 22: verification happens here and nowhere else. Rule 27: a correct entry | |
| /// resets the count, a wrong one advances it. | |
| let verify (pin: Pin) (c: UserCredential) = | |
| match c.Pin with | |
| | Some p when p = pin -> true, { c with AttemptCount = 0 } | |
| | _ -> false, { c with AttemptCount = c.AttemptCount + 1 } | |
| /// Rule 27: a wrong entry at the limit ends the Session (Rule 9). | |
| let atLimit c = c.AttemptCount >= wrongPinLimit | |
| let attemptsLeft c = max 0 (wrongPinLimit - c.AttemptCount) | |
| // ───────────────────────────── the session record ───────────────────────────── | |
| module SessionRecord = | |
| /// Rule 10, on the one axis that decides it: a User who closed was offered the | |
| /// save, so there is nothing to tell them. Every other ending owes a notice. | |
| /// Three branches, not four: a Server restart is no longer an ending (Rule 9). | |
| let owesNotice = | |
| function | |
| | ClosedByUser -> false | |
| | Idle | Superseded | WrongPinLimit -> true | |
| /// Ending is idempotent: an already settled record is left alone, so the first | |
| /// mark is the one that stands — and so is the obligation it created. | |
| let endWith mark now (s: SessionRecord) = | |
| match s.State with | |
| | Ended _ -> s | |
| | OpenOrGone -> | |
| { s with | |
| State = Ended(mark, now) | |
| Notice = if owesNotice mark then Owed else NotOwed } | |
| /// Rule 10. Told once, never twice — now a single field, not a pair that could | |
| /// disagree with `State`. | |
| let tellsAtNextOpportunity (s: SessionRecord) = s.Notice = Owed | |
| /// Whether ending this record now would leave the User owed a notice: either it | |
| /// is still open, and this launch is about to close it (Rule 7), or it ended | |
| /// earlier in a way nobody has yet mentioned. | |
| let wouldOweNotice (s: SessionRecord) = | |
| match s.State with | |
| | OpenOrGone -> true | |
| | Ended _ -> tellsAtNextOpportunity s | |
| /// Spends the one telling the User gets. Idempotent, and silent where nothing was | |
| /// owed — there is no way to be told twice. | |
| let told now (s: SessionRecord) = | |
| match s.Notice with | |
| | Owed -> { s with Notice = Told now } | |
| | NotOwed | |
| | Told _ -> s | |
| /// Rule 8. The idle clock lives on the record, because it is the only thing that | |
| /// outlives a request. | |
| let seen now (s: SessionRecord) = { s with LastSeen = now } | |
| let isOpen (s: SessionRecord) = s.State = OpenOrGone | |
| let userId (s: SessionRecord) = s.User |> Option.map _.UserId | |
| // ───────────────────────────── the tokens ───────────────────────────── | |
| /// Rules 33 and 34. The Client holds the cart, so anything the Server must be able to | |
| /// trust about a create has to be something the Client cannot forge. Both tokens are | |
| /// the same trick: the Server states a fact, signs it, and refuses to believe the | |
| /// fact unless the signature comes back with it. | |
| /// | |
| /// `secret` stands in for the key. It is `private`, so nothing outside this module — | |
| /// no scenario, no forgery test — can compute a mac. That is the point: the tests | |
| /// below can build a token with the right fields and a wrong mac, and watch it fail. | |
| module Token = | |
| let private secret = "hmac-key-known-only-to-the-server" | |
| let private sid (SessionId s) = s | |
| let private pat = function Some(PatientId p) -> p | None -> "-" | |
| let private snap = function Some(TreatmentPlanId s) -> s | None -> "-" | |
| let private macOpened s p n = | |
| $"mac|%s{secret}|opened|%s{sid s}|%s{pat p}|%s{snap n}" | |
| let private macNotice s (ids: TreatmentPlanId list) = | |
| let named = ids |> List.map (fun (TreatmentPlanId i) -> i) |> List.sort |> String.concat "," | |
| $"mac|%s{secret}|notice|%s{sid s}|%s{named}" | |
| /// Rule 33. Minted at the opening of a Session, and re-minted whenever the | |
| /// baseline moves: an open (Rule 17) or a create both make a new TreatmentPlan the one | |
| /// Rules 20 and 21 are measured from. | |
| let mintOpened s p n : OpenedToken = | |
| { Sid = s; Patient = p; Plan = n; Mac = macOpened s p n } | |
| /// Rule 34. Minted with the notice, naming exactly the Unsigned TreatmentPlans it | |
| /// disclosed. | |
| let mintNotice s (ids: TreatmentPlanId list) : NoticeToken = | |
| { Sid = s; Disclosed = ids; Mac = macNotice s ids } | |
| /// Recompute and compare. A token whose fields were edited no longer matches its | |
| /// mac, and a token whose mac was guessed does not match its fields. | |
| let verifyOpened (t: OpenedToken) = | |
| t.Mac = macOpened t.Sid t.Patient t.Plan | |
| let verifyNotice (t: NoticeToken) = | |
| t.Mac = macNotice t.Sid t.Disclosed | |
| // ───────────────────────────── the reducer ───────────────────────────── | |
| module Hospital = | |
| let empty = | |
| { | |
| Workstation = { ActiveUser = None; ActivePatient = None; NextTab = 1 } | |
| Broker = { Launches = Map.empty; NextNo = 1; Up = true } | |
| Registry = { Users = Map.empty; Up = true } | |
| Platform = { Data = Map.empty; Up = true } | |
| Database = | |
| { | |
| Sessions = [] | |
| Records = Map.empty | |
| Credentials = Map.empty | |
| NextPlan = 1 | |
| } | |
| GenPres = | |
| { | |
| InFlight = Map.empty | |
| Pending = Map.empty | |
| NextAttempt = 1 | |
| NextRequest = 1 | |
| NextSessionId = 1 | |
| Up = true | |
| } | |
| Clients = Map.empty | |
| Mail = [] | |
| Audit = [] | |
| Env = { Now = 0 } | |
| } | |
| let blankClient = | |
| { | |
| UrlCredential = None | |
| Sid = None | |
| User = None | |
| Patient = None | |
| Working = [] | |
| Data = None | |
| Opened = None | |
| Notice = None | |
| AwaitingPin = None | |
| AnonymousOffer = false | |
| Showing = None | |
| Closed = false | |
| } | |
| let private onClient id f h = | |
| let current = h.Clients |> Map.tryFind id |> Option.defaultValue blankClient | |
| { h with Clients = h.Clients |> Map.add id (f current) } | |
| let private clientState id h = | |
| h.Clients |> Map.tryFind id |> Option.defaultValue blankClient | |
| let private send from to_ msg = { From = from; To = to_; Msg = msg } | |
| let private pend now stage = { Stage = stage; Since = now } | |
| // ── the in-flight table (Rule 31) ── | |
| let private putFlight rid ctx (h: Hospital) = | |
| { h with GenPres.InFlight = h.GenPres.InFlight |> Map.add rid ctx } | |
| let private dropFlight rid (h: Hospital) = | |
| { h with GenPres.InFlight = h.GenPres.InFlight |> Map.remove rid } | |
| /// Rule 26, both halves: the User is mailed, and the change is recorded. | |
| let private pinChanged (mail: MailAddress option) (what: string) = | |
| [ | |
| match mail with | |
| | Some m -> send GenPresServer MailService (SendMail(m, what)) | |
| | None -> () | |
| send GenPresServer Environment (Noted what) | |
| ] | |
| // ── Rule 35: the stamps are the Server's to compute ── | |
| /// Rule 14 says an OrderContext changed in the Session is stamped with the | |
| /// Session's User and an unchanged one keeps the stamp it had. With the cart in | |
| /// the Client (Rule 31) there is no Session to ask what changed, and no reason to | |
| /// believe a Client that says so — so the Server diffs the payload against the | |
| /// base TreatmentPlan by OrderContextId. Same id and same content: the base's stamp | |
| /// stands. New id, or changed content: this User's stamp. Whatever stamp arrived | |
| /// is discarded unread (Rule 35). | |
| let private stampAgainst (uc: UserContext) (basePlan: TreatmentPlan option) (orders: OrderContext list) = | |
| let baseline = basePlan |> Option.map _.Orders |> Option.defaultValue [] | |
| orders | |
| |> List.map (fun o -> | |
| match baseline |> List.tryFind (fun b -> b.Id = o.Id) with | |
| | Some b when b.Content = o.Content -> { o with Stamp = b.Stamp } | |
| | _ -> { o with Stamp = Some uc }) | |
| // ── the launch, and what ends it ── | |
| /// UC-1 steps 13 and 14, and the last step of the anonymous open. Rule 19 has | |
| /// already picked the TreatmentPlan the Session starts from, if there is one, and Rule | |
| /// 7's other Sessions of this User have already been read back from the Database | |
| /// — the Server keeps no copy of them (Rule 31). | |
| let private openSession | |
| (client: ActorId) | |
| (launch: LaunchNo option) | |
| (user: UserContext option) | |
| (mail: MailAddress option) | |
| (pctx: PatientContext) | |
| (start: TreatmentPlan option) | |
| (others: SessionRecord list) | |
| (h: Hospital) = | |
| let sid = SessionId $"sid-%04i{h.GenPres.NextSessionId}" | |
| let no = SessionNo h.GenPres.NextSessionId | |
| // Rules 7 and 10 both speak of the Session's User, so neither applies to an | |
| // anonymous open (Rule 13). Rule 7 is per User, not per Patient: this closes | |
| // every other Session of *this* User, whichever Patient it was opened for, | |
| // and closes nobody else's. | |
| let priors = | |
| match user with | |
| | None -> [] | |
| | Some uc -> | |
| others | |
| |> List.filter (fun r -> | |
| SessionRecord.userId r = Some uc.UserId && SessionRecord.wouldOweNotice r) | |
| // endWith leaves an already settled record alone, so a Session that | |
| // idled out keeps Idle — and keeps the obligation that ending created, | |
| // which this launch is the opportunity to discharge. | |
| |> List.map ( | |
| SessionRecord.endWith Superseded h.Env.Now | |
| >> SessionRecord.told h.Env.Now) | |
| let orders = start |> Option.map _.Orders |> Option.defaultValue [] | |
| let record = | |
| { | |
| Id = sid | |
| No = no | |
| User = user | |
| Mail = mail | |
| Patient = pctx.Patient | |
| Launch = launch | |
| OpenedAt = h.Env.Now | |
| LastSeen = h.Env.Now | |
| State = OpenOrGone | |
| Notice = NotOwed | |
| } | |
| // Rule 33. The Client gets the TreatmentPlan the Session opened with as something | |
| // it can hand back but not make. | |
| let token = Token.mintOpened sid pctx.Patient (start |> Option.map _.Id) | |
| { h with GenPres.NextSessionId = h.GenPres.NextSessionId + 1 }, | |
| [ | |
| send GenPresServer GenPresDatabase (WriteSessionRecord record) | |
| for r in priors do | |
| send GenPresServer GenPresDatabase (WriteSessionRecord r) | |
| send GenPresServer client (SessionOpened(sid, no, user, pctx, orders, token)) | |
| if not priors.IsEmpty then | |
| send GenPresServer client | |
| (PriorSessionNotice(priors |> List.map (fun r -> r.No, r.State))) | |
| ] | |
| /// UC-1 steps 11 and 12, and where they are skipped. A Reader arrives here | |
| /// straight from the registry (Rule 25); a Prescriber only once the PIN question | |
| /// is settled (Rules 23, 24). | |
| let private afterCredential att (ctx: LaunchCtx) uc mail (h: Hospital) = | |
| match ctx.Assertion.Patient with | |
| | None -> | |
| // ext 1a: no Patient, so no data to fetch and no record to read. Rule 7 | |
| // still applies — this User's other Sessions close — so the SessionRecords | |
| // are still read. | |
| let pctx = { Patient = None; Data = None } | |
| { h with | |
| GenPres.Pending = | |
| h.GenPres.Pending | |
| |> Map.add att (pend h.Env.Now (AwaitingPriors(ctx, uc, mail, pctx, None))) }, | |
| [ send GenPresServer GenPresDatabase (ReadSessionRecords(ForLaunch att)) ] | |
| | Some p -> | |
| { h with | |
| GenPres.Pending = | |
| h.GenPres.Pending |> Map.add att (pend h.Env.Now (AwaitingPatientData(ctx, uc, mail))) }, | |
| [ send GenPresServer PatientDataPlatform (ReadPatientData(att, p)) ] | |
| /// Rule 6. A launch that cannot be honoured opens no Session. There is no silent | |
| /// fallback; where relaunching would not cure it, the Client is left to offer a | |
| /// fresh anonymous open, which carries nothing over. | |
| let private refuseLaunch att client reply (h: Hospital) = | |
| { h with GenPres.Pending = h.GenPres.Pending |> Map.remove att }, | |
| [ send GenPresServer client reply ] | |
| // ── creating a TreatmentPlan: one request, several Database legs, no Session state ── | |
| /// Rule 36. The Server hands over the head its Rule 20 check saw; the Database | |
| /// appends only if that is still the head. The draft has no Id — that is the | |
| /// Database's to mint, because the Database is what orders the record. | |
| let private appendDraft | |
| rid (ctx: RequestCtx) (r: SessionRecord) (pr: PatientRecord) | |
| (basePlan: TreatmentPlan option) (uc: UserContext) (pin: Pin option) | |
| (orders: OrderContext list) (h: Hospital) = | |
| match r.Patient with | |
| | None -> dropFlight rid h, [ send GenPresServer ctx.Client NoTreatmentPlanHere ] // Rule 12 | |
| | Some p -> | |
| let draft = | |
| { | |
| Patient = p // Guarantee 1, Rule 32 | |
| By = uc // Rule 14 | |
| Base = basePlan |> Option.map _.Id // Concept 13 | |
| Orders = orders |> stampAgainst uc basePlan // Rule 35 | |
| Signed = pin.IsSome // Rules 15, 16 | |
| } | |
| h |> putFlight rid { ctx with Stage = AwaitingAppend r }, | |
| [ send GenPresServer GenPresDatabase (AppendIf(ForRequest rid, PatientRecord.head pr, draft)) ] | |
| /// Rules 20 and 21, in that order, then the PIN (Rule 22). UC-3 ext 3c fixes the | |
| /// order: signing without saving first is one act, and the block and the | |
| /// notification are settled before the User is ever asked for a PIN. | |
| let private decideCreate rid (ctx: RequestCtx) (r: SessionRecord) (pr: PatientRecord) (h: Hospital) = | |
| match ctx.Cmd, r.User with | |
| | CreateTreatmentPlan(pin, orders, _, opened, notice), Some uc -> | |
| let openedWith = opened.Plan | |
| let basePlan = pr.Plans |> List.tryFind (fun s -> Some s.Id = openedWith) | |
| match PatientRecord.blocking openedWith pr with | |
| | Some blocker -> | |
| // Rule 20. The remedy is to open that Signed TreatmentPlan (Rule 17), which | |
| // makes it the one the Session opened with and lifts the block. | |
| dropFlight rid h, [ send GenPresServer ctx.Client (CreateBlocked blocker.Id) ] | |
| | None -> | |
| let outstanding = pr |> PatientRecord.unsignedElsewhere uc.UserId openedWith | |
| // Rule 34. The token is honoured for exactly the Unsigned TreatmentPlans | |
| // the notice disclosed, and for nothing newer. A token the Client made | |
| // itself does not verify, so it counts as none at all. | |
| let honoured = | |
| match notice with | |
| | Some t when Token.verifyNotice t && t.Sid = ctx.Sid -> Set.ofList t.Disclosed | |
| | _ -> Set.empty | |
| match outstanding |> List.filter (fun s -> not (honoured.Contains s.Id)) with | |
| | undisclosed :: _ -> | |
| // Rule 21. Whose work it is, not its contents. The User may still | |
| // say no, and saying yes means coming back with this token. | |
| let token = Token.mintNotice ctx.Sid (outstanding |> List.map _.Id) | |
| dropFlight rid h, | |
| [ send GenPresServer ctx.Client (UnsignedWorkNotice(undisclosed.By, token)) ] | |
| | [] -> | |
| match pin with | |
| | None -> appendDraft rid ctx r pr basePlan uc None orders h // a save: Unsigned | |
| | Some _ -> | |
| // Rule 22: the Server is the only party that verifies, so the | |
| // credential has to be fetched before anything is signed. | |
| h |> putFlight rid { ctx with Stage = AwaitingUserCredential(r, pr, basePlan) }, | |
| [ send GenPresServer GenPresDatabase (ReadCredential(ForRequest rid, uc.Login)) ] | |
| | _ -> dropFlight rid h, [] | |
| /// The SessionRecord has come back, the Session is open, and Rule 8's clock has | |
| /// been refreshed. This is where Rule 32 bites: the User and the Patient of the | |
| /// request are read off the record, and the payload is believed about nothing | |
| /// else. Concept 15 — what a User may do inside a Session, and what they may not. | |
| let private dispatch rid (ctx: RequestCtx) (r: SessionRecord) (h: Hospital) = | |
| let refuse msg = dropFlight rid h, [ send GenPresServer ctx.Client msg ] | |
| /// Rule 12: a Session without a PatientId lets the User prescribe, Patient | |
| /// Data included, but a TreatmentPlan cannot be opened or created. | |
| let withPatient f = | |
| match r.Patient with | |
| | None -> refuse NoTreatmentPlanHere | |
| | Some p -> f p | |
| /// Roles: a Reader may never create a TreatmentPlan. Rule 13: an anonymous Session | |
| /// has no User at all, so there is nobody to create as and nobody to sign as. | |
| let withPrescriber f = | |
| match r.User with | |
| | Some uc when uc.Role = Prescriber -> f uc | |
| | _ -> refuse NotPermitted | |
| match ctx.Cmd with | |
| | Compute orders -> | |
| // Rule 31. The answer is computed from the payload and nothing is kept — | |
| // the cart went home with the reply, as it arrived with the request. | |
| dropFlight rid h, [ send GenPresServer ctx.Client (Computed orders) ] | |
| | CloseSession -> | |
| // Rule 9: closing is an explicit act in the Client. Rule 10 adds nothing | |
| // — it speaks only of endings other than by the User. | |
| let ended = r |> SessionRecord.endWith ClosedByUser h.Env.Now | |
| dropFlight rid h, [ send GenPresServer GenPresDatabase (WriteSessionRecord ended) ] | |
| | ResetPin -> | |
| // UC-7 step 2. The UserCredential stays, now without a PIN; Concept 7 says | |
| // one without a PIN cannot sign. | |
| match r.User with | |
| | None -> refuse NotPermitted | |
| | Some uc -> | |
| h |> putFlight rid { ctx with Stage = AwaitingPinRemoved r }, | |
| [ send GenPresServer GenPresDatabase (RemovePin(ForRequest rid, uc.Login)) ] | |
| | OpenTreatmentPlan _ -> | |
| withPatient (fun p -> | |
| match r.User with | |
| | None -> refuse NotPermitted // Rule 13 | |
| | Some _ -> | |
| h |> putFlight rid { ctx with Stage = AwaitingPatientRecord r }, | |
| [ send GenPresServer GenPresDatabase (ReadRecord(ForRequest rid, p)) ]) | |
| | CreateTreatmentPlan(_, orders, _, opened, _) -> | |
| withPatient (fun p -> | |
| withPrescriber (fun _ -> | |
| // Rule 33. The Client cannot make an opened-with token, and this | |
| // is where that is worth something: the baseline Rules 20 and 21 | |
| // are measured from is the Server's own word, handed back. | |
| if not (Token.verifyOpened opened) then | |
| refuse (CreateRefused "the opened-with token does not verify (Rule 33)") | |
| elif opened.Sid <> ctx.Sid || opened.Patient <> r.Patient then | |
| refuse (CreateRefused "the opened-with token is for another Session (Rule 33)") | |
| // Rule 32 and Guarantee 1. The PatientId is the one thing no | |
| // TreatmentPlan may change, and the payload does not get a vote on it. | |
| elif orders |> List.exists (fun o -> o.Patient <> None && o.Patient <> Some p) then | |
| refuse (CreateRefused "an OrderContext names another Patient (Rule 32)") | |
| else | |
| h |> putFlight rid { ctx with Stage = AwaitingPatientRecord r }, | |
| [ send GenPresServer GenPresDatabase (ReadRecord(ForRequest rid, p)) ])) | |
| // ══════════════════════════════════════════════════════════════════════════ | |
| // The reducer proper. Dispatch names the sender as well as the recipient, so | |
| // every branch states who may send it. Whether the two may exchange anything | |
| // at all was already settled by the edge table, in `run`, before we get here. | |
| // ══════════════════════════════════════════════════════════════════════════ | |
| let rec update (h: Hospital) (env: Envelope) : Hospital * Envelope list = | |
| // every move takes a tick of time | |
| let h = { h with Env.Now = h.Env.Now + 1 } | |
| match env.From, env.To, env.Msg with | |
| // ── the audit log, and the person ── | |
| // Recorded, not acted on. Handled first, and never refused itself: refusing a | |
| // refusal would not terminate. | |
| | _, Environment, Refused e -> | |
| let line = $"REFUSED %A{e.From} -> %A{e.To}" | |
| { h with Audit = line :: h.Audit }, [] | |
| | _, Environment, Noted what -> { h with Audit = what :: h.Audit }, [] | |
| // A person reads what is sent to them; there is no state to change. | |
| | _, User, _ -> h, [] | |
| // ── the clock ── | |
| // The clock is advanced by the prefix above, on this envelope like any other, | |
| // so a Tick adds nothing of its own: it exists to reach the Server, whose | |
| // sweep runs on nothing else. | |
| | Environment, Environment, Tick -> | |
| h, [ send Environment GenPresServer Tick ] | |
| // ── infrastructure ── | |
| | Environment, Broker, Stop _ -> { h with Broker.Up = false }, [] | |
| | Environment, Broker, Start _ -> { h with Broker.Up = true }, [] | |
| | Environment, UserRegistry, Stop _ -> { h with Registry.Up = false }, [] | |
| | Environment, UserRegistry, Start _ -> { h with Registry.Up = true }, [] | |
| | Environment, PatientDataPlatform, Stop _ -> { h with Platform.Up = false }, [] | |
| | Environment, PatientDataPlatform, Start _ -> { h with Platform.Up = true }, [] | |
| // Rule 9: a Server restart ends nothing. There is no Session state to lose — | |
| // identity and standing are in the SessionRecords, the work is in the Clients | |
| // (Rule 31). What does go is what was in flight at that instant: requests | |
| // half-way through their Database legs, and launches half-way through theirs. | |
| // Their Clients see the same silence as any other unreachable Server. | |
| | Environment, GenPresServer, Stop _ when h.GenPres.Up -> | |
| { h with | |
| GenPres = | |
| { h.GenPres with | |
| InFlight = Map.empty | |
| Pending = Map.empty | |
| Up = false } }, [] | |
| // And coming back settles nothing either: no records to read, nothing to mark. | |
| | Environment, GenPresServer, Start _ when not h.GenPres.Up -> | |
| { h with GenPres.Up = true }, [] | |
| | Environment, GenPresServer, (Start _ | Stop _) -> h, [] | |
| // A Server that is down answers its clients and does nothing else. Ordering | |
| // matters twice over: this pair must sit above every other Server branch, and | |
| // the client-facing case must be the narrow one. A reply from the Broker, the | |
| // registry, the platform or the Database is an in-flight answer to a Server | |
| // that is gone — dropped, not answered. Ticks are dropped too: a down Server | |
| // runs no sweeps. | |
| | _, GenPresServer, | |
| (RedeemLaunch _ | OpenAnonymous | SupplyPin _ | SessionRequest _) when not h.GenPres.Up -> | |
| h, [ send GenPresServer env.From ServerUnreachable ] | |
| | _, GenPresServer, _ when not h.GenPres.Up -> h, [] | |
| // ── Rule 9: the idle sweep ── | |
| // The clock a Session is swept against is on its SessionRecord (Rule 8), and | |
| // the records are in the Database (Rule 31), so the sweep is a read like any | |
| // other rather than a walk over something the Server holds. | |
| | Environment, GenPresServer, Tick -> | |
| let now = h.Env.Now | |
| // A launch nobody is coming back for. Every stage is waiting on a round | |
| // trip that should return promptly — except AwaitingPinChoice, which the | |
| // document suspends on a human and which may therefore sit for as long as | |
| // it likes (UC-2 step 3). Bounded by Rule 29's constant: Rule 28's belongs | |
| // to the Broker, and GenPRES never sees it. | |
| let abandoned (p: PendingEntry) = | |
| match p.Stage with | |
| | AwaitingPinChoice _ -> false | |
| | _ -> now - p.Since > sessionTtl | |
| { h with | |
| GenPres.Pending = | |
| h.GenPres.Pending |> Map.filter (fun _ p -> not (abandoned p)) }, | |
| [ send GenPresServer GenPresDatabase (ReadSessionRecords ForSweep) ] | |
| | GenPresDatabase, GenPresServer, SessionRecordsRead(ForSweep, rs) -> | |
| // Rule 13: an anonymous Session need not idle out — keeping it has no | |
| // consequence — so only a Session bound to a User is swept. | |
| let now = h.Env.Now | |
| let stale = | |
| rs | |
| |> List.filter (fun r -> | |
| SessionRecord.isOpen r && r.User.IsSome && now - r.LastSeen > sessionTtl) | |
| h, | |
| [ for r in stale -> | |
| send GenPresServer GenPresDatabase | |
| (WriteSessionRecord(r |> SessionRecord.endWith Idle now)) ] | |
| // ── Actor 1: the MainEHR Workstation ── | |
| | User, MainEhrWorkstation, LogIn u -> { h with Workstation.ActiveUser = Some u }, [] | |
| | User, MainEhrWorkstation, SelectPatient p -> { h with Workstation.ActivePatient = Some p }, [] | |
| | User, MainEhrWorkstation, ClearPatient -> { h with Workstation.ActivePatient = None }, [] | |
| // ── Actor 2: the MainEHR LaunchScript ── | |
| // Rule 1: the LaunchScript decides which MainEHR User may run it. It is a | |
| // script behind a button *in* the Workstation, so the login and the active | |
| // Patient are its own context, not something fetched over an edge — there is | |
| // no edge between Actors 1 and 2, and none is needed. | |
| // | |
| // Note what it does NOT do: it never asks about, decides on, or transmits a | |
| // Role. The launch carries a login and a Patient (Concept 3), and the Role | |
| // comes from the UserRegistry at the far end (Rule 5). | |
| | User, MainEhrLaunchScript, TriggerLaunch -> | |
| match h.Workstation.ActiveUser with | |
| | Some u -> | |
| // ext 1a: no active Patient is not an error. The launch goes without | |
| // one, and GenPRES opens with no Patient (Rule 12). | |
| h, [ send MainEhrLaunchScript Broker (PrepareLaunch(u, h.Workstation.ActivePatient)) ] | |
| | None -> | |
| h, [ send MainEhrLaunchScript User (LaunchError "no MainEHR login: nobody to launch as") ] | |
| | Broker, MainEhrLaunchScript, LaunchPrepared cred -> | |
| let tab = BrowserId h.Workstation.NextTab | |
| // The launch, and then the LaunchScript exits. Consequence 1 is not a | |
| // promise made here: edge C4 is `=>`, so nothing can be sent back at all. | |
| { h with Workstation.NextTab = h.Workstation.NextTab + 1 }, | |
| [ send MainEhrLaunchScript (GenPresClient tab) (OpenUrl cred) ] | |
| // UC-1 ext 3a. The one launch failure the EHR side can report: the Broker | |
| // edge is request-response and the LaunchScript has not yet exited. | |
| | Broker, MainEhrLaunchScript, LaunchNotPrepared -> | |
| h, [ send MainEhrLaunchScript User (LaunchError "the Broker is unreachable — stay in MainEHR") ] | |
| // ── Actor 8: the Broker ── | |
| | MainEhrLaunchScript, Broker, PrepareLaunch _ when not h.Broker.Up -> | |
| h, [ send Broker env.From LaunchNotPrepared ] | |
| | GenPresServer, Broker, ResolveLaunch(att, _) when not h.Broker.Up -> | |
| h, [ send Broker env.From (LaunchRejected(att, None, BrokerUnreachable)) ] | |
| | MainEhrLaunchScript, Broker, PrepareLaunch(login, patient) -> | |
| let cred = LaunchCredential $"cred-%04i{h.Broker.NextNo}" | |
| let record = | |
| { | |
| Credential = cred | |
| No = LaunchNo h.Broker.NextNo | |
| Login = login | |
| Patient = patient | |
| IssuedAt = h.Env.Now | |
| Redeemed = false | |
| } | |
| { h with | |
| Broker.Launches = h.Broker.Launches |> Map.add cred record | |
| Broker.NextNo = h.Broker.NextNo + 1 }, | |
| [ send Broker env.From (LaunchPrepared cred) ] | |
| // Rules 2, 3 and 4. Rule 4 is this branch's shape: only the Server appears in | |
| // the From position, so no other party can redeem. | |
| | GenPresServer, Broker, ResolveLaunch(att, cred) -> | |
| let reject no f = [ send Broker env.From (LaunchRejected(att, no, f)) ] | |
| match h.Broker.Launches |> Map.tryFind cred with | |
| | None -> h, reject None NotFound | |
| | Some l when l.Redeemed -> h, reject (Some l.No) AlreadyRedeemed // Rule 2 | |
| | Some l when h.Env.Now - l.IssuedAt > credentialTtl -> | |
| h, reject (Some l.No) CredentialExpired // Rule 3 | |
| | Some l -> | |
| { h with | |
| Broker.Launches = h.Broker.Launches |> Map.add cred { l with Redeemed = true } }, | |
| [ send Broker env.From | |
| (LaunchResolved(att, l.No, { Login = l.Login; Patient = l.Patient })) ] | |
| // ── Actor 9: the UserRegistry ── | |
| | GenPresServer, UserRegistry, ResolveUser(att, _) when not h.Registry.Up -> | |
| h, [ send UserRegistry env.From (UserUnresolved(att, RegistryUnreachable)) ] | |
| | GenPresServer, UserRegistry, ResolveUser(att, login) -> | |
| match h.Registry.Users |> Map.tryFind login with | |
| | Some(uc, mail) -> h, [ send UserRegistry env.From (UserResolved(att, uc, mail)) ] | |
| | None -> h, [ send UserRegistry env.From (UserUnresolved(att, NoRole)) ] | |
| // ── Actor 6: the PatientDataPlatform ── | |
| // Concept 2: read once, at the launch. Whether it is down or simply holds | |
| // nothing for this Patient makes no difference to the caller (ext 11a). | |
| | GenPresServer, PatientDataPlatform, ReadPatientData(att, p) -> | |
| match (if h.Platform.Up then h.Platform.Data |> Map.tryFind p else None) with | |
| | Some d -> h, [ send PatientDataPlatform env.From (PatientDataRead(att, d)) ] | |
| | None -> h, [ send PatientDataPlatform env.From (PatientDataUnavailable att) ] | |
| // ── Actor 10: the MailService ── | |
| | GenPresServer, MailService, SendMail(addr, what) -> | |
| { h with Mail = (addr, what) :: h.Mail }, [] | |
| // ── Actor 5: the GenPRES Database. The Server is its only writer. ── | |
| | GenPresServer, GenPresDatabase, ReadCredential(tag, login) -> | |
| h, [ send GenPresDatabase env.From (CredentialRead(tag, h.Database.Credentials |> Map.tryFind login)) ] | |
| | GenPresServer, GenPresDatabase, WriteCredential(tag, c) -> | |
| { h with Database.Credentials = h.Database.Credentials |> Map.add c.Login c }, | |
| [ send GenPresDatabase env.From (CredentialWritten(tag, c)) ] | |
| | GenPresServer, GenPresDatabase, ReadRecord(tag, p) -> | |
| let r = | |
| h.Database.Records | |
| |> Map.tryFind p | |
| |> Option.defaultValue (PatientRecord.empty p) | |
| h, [ send GenPresDatabase env.From (RecordRead(tag, r)) ] | |
| // Rule 36. The Rule 20 check and the append are one act, and this is where it | |
| // is made one: the Server states the head its check saw, and the TreatmentPlan | |
| // lands only if that is still the head. More than one Server may run — the | |
| // arbitration is here, not in an assumption that only one of them writes. | |
| // | |
| // Concept 12: append-only. Nothing already in the record is touched. The Id | |
| // and the ordering are minted here, because ordering a record is the same | |
| // authority as deciding what may join it. | |
| | GenPresServer, GenPresDatabase, AppendIf(tag, expected, draft) -> | |
| let r = | |
| h.Database.Records | |
| |> Map.tryFind draft.Patient | |
| |> Option.defaultValue (PatientRecord.empty draft.Patient) | |
| match PatientRecord.head r with | |
| | actual when actual = expected -> | |
| let plan = | |
| { | |
| Id = TreatmentPlanId $"plan-%04i{h.Database.NextPlan}" | |
| No = TreatmentPlanNo h.Database.NextPlan | |
| Patient = draft.Patient | |
| By = draft.By | |
| Base = draft.Base | |
| Orders = draft.Orders | |
| Signed = draft.Signed | |
| At = h.Env.Now | |
| } | |
| { h with | |
| Database.Records = | |
| h.Database.Records |> Map.add draft.Patient (r |> PatientRecord.append plan) | |
| Database.NextPlan = h.Database.NextPlan + 1 }, | |
| [ send GenPresDatabase env.From (TreatmentPlanAppended(tag, plan)) ] | |
| | Some actual -> | |
| h, [ send GenPresDatabase env.From (AppendRefused(tag, actual)) ] | |
| | None -> | |
| // The head the Server saw is gone from an append-only record: cannot | |
| // happen. Refusing is the safe answer, and names what it saw instead. | |
| h, [ send GenPresDatabase env.From (AppendRefused(tag, TreatmentPlanId "none")) ] | |
| | GenPresServer, GenPresDatabase, WriteSessionRecord r -> | |
| let existing = h.Database.Sessions |> List.exists (fun x -> x.Id = r.Id) | |
| { h with | |
| Database.Sessions = | |
| if existing then h.Database.Sessions |> List.map (fun x -> if x.Id = r.Id then r else x) | |
| else r :: h.Database.Sessions }, [] | |
| | GenPresServer, GenPresDatabase, ReadSessionRecord(tag, sid) -> | |
| h, | |
| [ send GenPresDatabase env.From | |
| (SessionRecordRead(tag, h.Database.Sessions |> List.tryFind (fun x -> x.Id = sid))) ] | |
| | GenPresServer, GenPresDatabase, ReadSessionRecords tag -> | |
| h, [ send GenPresDatabase env.From (SessionRecordsRead(tag, h.Database.Sessions)) ] | |
| // UC-7 step 2. Distinct from a WriteCredential so that removing a PIN is | |
| // never confused with the write-back of a wrong-entry count (Rule 27) — the | |
| // two would otherwise look identical once the PIN is gone. | |
| | GenPresServer, GenPresDatabase, RemovePin(tag, login) -> | |
| let c = | |
| h.Database.Credentials | |
| |> Map.tryFind login | |
| |> Option.defaultValue (UserCredential.fresh login) | |
| |> UserCredential.removePin | |
| { h with Database.Credentials = h.Database.Credentials |> Map.add login c }, | |
| [ send GenPresDatabase env.From (PinRemoved(tag, c)) ] | |
| // ══════════════════════════════════════════════════════════════════════ | |
| // Actor 4: the GenPRES Server. A launch, leg by leg — UC-1 steps 7 to 14. | |
| // ══════════════════════════════════════════════════════════════════════ | |
| // Step 7 into 8. The credential is handed to the Broker and not kept: from | |
| // here on GenPRES holds only the launch number, which is safe to log. | |
| | GenPresClient _, GenPresServer, RedeemLaunch cred -> | |
| let att = AttemptId h.GenPres.NextAttempt | |
| { h with | |
| GenPres.NextAttempt = h.GenPres.NextAttempt + 1 | |
| GenPres.Pending = h.GenPres.Pending |> Map.add att (pend h.Env.Now (AwaitingAssertion env.From)) }, | |
| [ send GenPresServer Broker (ResolveLaunch(att, cred)) ] | |
| // Rule 6. A refusal opens nothing. LaunchRefused carries no reason | |
| // deliberately: expired, spent and never-existed are one answer to a Client. | |
| | Broker, GenPresServer, LaunchRejected(att, _, _) -> | |
| match h.GenPres.Pending |> Map.tryFind att |> Option.map _.Stage with | |
| | Some(AwaitingAssertion client) -> refuseLaunch att client LaunchRefused h | |
| | _ -> h, [] // a late or duplicate answer | |
| // Step 8 into 9. What the launch asserted — a login and maybe a Patient. Now | |
| // ask who that login is; the credential does not travel to the registry. | |
| | Broker, GenPresServer, LaunchResolved(att, no, assertion) -> | |
| match h.GenPres.Pending |> Map.tryFind att |> Option.map _.Stage with | |
| | Some(AwaitingAssertion client) -> | |
| let ctx = { Client = client; Launch = no; Assertion = assertion } | |
| { h with GenPres.Pending = h.GenPres.Pending |> Map.add att (pend h.Env.Now (AwaitingUser ctx)) }, | |
| [ send GenPresServer UserRegistry (ResolveUser(att, assertion.Login)) ] | |
| | _ -> h, [] | |
| // Step 9. Rule 6: no Role, no Session — and no guessing either. | |
| | UserRegistry, GenPresServer, UserUnresolved(att, failure) -> | |
| match h.GenPres.Pending |> Map.tryFind att |> Option.map _.Stage with | |
| | Some(AwaitingUser ctx) -> | |
| let reply = | |
| match failure with | |
| | NoRole -> NotAuthorised | |
| | RegistryUnreachable -> AuthorityUnavailable | |
| refuseLaunch att ctx.Client reply h | |
| | _ -> h, [] | |
| // Step 9 into 10. Rule 5: the Role is the registry's answer, never the | |
| // launch's — the launch never carried one (Concept 3). | |
| | UserRegistry, GenPresServer, UserResolved(att, uc, mail) -> | |
| match h.GenPres.Pending |> Map.tryFind att |> Option.map _.Stage with | |
| | Some(AwaitingUser ctx) -> | |
| match uc.Role with | |
| // Rule 25: a Reader is never asked for a PIN. Not asked and ignored — | |
| // not asked at all: the credential stage is skipped whole. | |
| | Reader -> afterCredential att ctx uc mail h | |
| // Rule 23: every launch checks whether a PIN is set for the login. | |
| | Prescriber -> | |
| { h with | |
| GenPres.Pending = | |
| h.GenPres.Pending |> Map.add att (pend h.Env.Now (AwaitingCredential(ctx, uc, mail))) }, | |
| [ send GenPresServer GenPresDatabase (ReadCredential(ForLaunch att, uc.Login)) ] | |
| | _ -> h, [] | |
| // Step 10, and UC-2 step 1. Rule 24: a Prescriber with no PIN must set one | |
| // before the launch continues — and only now, once the registry has said who | |
| // the login belongs to. A login the registry does not recognise never reaches | |
| // this branch, so it can never enrol. | |
| | GenPresDatabase, GenPresServer, CredentialRead(ForLaunch att, credential) -> | |
| match h.GenPres.Pending |> Map.tryFind att |> Option.map _.Stage with | |
| | Some(AwaitingCredential(ctx, uc, mail)) -> | |
| match credential |> Option.bind _.Pin with | |
| | Some _ -> afterCredential att ctx uc mail h | |
| | None -> | |
| { h with | |
| GenPres.Pending = | |
| h.GenPres.Pending |> Map.add att (pend h.Env.Now (AwaitingPinChoice(ctx, uc, mail))) }, | |
| [ send GenPresServer ctx.Client (PinRequired att) ] | |
| | _ -> h, [] | |
| // UC-2 steps 3 and 4. The launch has been suspended on a human, possibly for | |
| // a long while, and nothing else was offered meanwhile. | |
| | GenPresClient _, GenPresServer, SupplyPin(att, pin) -> | |
| match h.GenPres.Pending |> Map.tryFind att |> Option.map _.Stage with | |
| | Some(AwaitingPinChoice(ctx, uc, mail)) -> | |
| // Creating the UserCredential if GenPRES holds none for that login | |
| // yet. Rule 26: a newly set PIN starts with a count of zero, so there | |
| // is nothing on an existing credential worth carrying over. | |
| let c = UserCredential.fresh uc.Login |> UserCredential.setPin pin | |
| { h with | |
| GenPres.Pending = | |
| h.GenPres.Pending |> Map.add att (pend h.Env.Now (AwaitingPinWritten(ctx, uc, mail))) }, | |
| [ send GenPresServer GenPresDatabase (WriteCredential(ForLaunch att, c)) ] | |
| | _ -> h, [] | |
| | GenPresDatabase, GenPresServer, CredentialWritten(ForLaunch att, _) -> | |
| match h.GenPres.Pending |> Map.tryFind att |> Option.map _.Stage with | |
| | Some(AwaitingPinWritten(ctx, uc, mail)) -> | |
| let (LoginName l) = uc.Login | |
| // Rule 26: mailed and recorded, the first setting included. Then the | |
| // launch continues from UC-1 step 11. | |
| let h, out = afterCredential att ctx uc mail h | |
| h, (pinChanged (Some mail) $"PIN set for %s{l}") @ out | |
| | _ -> h, [] | |
| // Step 11. Concept 2: read once, at the launch, and not refreshed while the | |
| // Session lives. ext 11a: unavailable is not a failure — the PatientContext | |
| // carries the PatientId and no data, and the User fills it in by hand. | |
| | PatientDataPlatform, GenPresServer, (PatientDataRead(att, _) | PatientDataUnavailable att) -> | |
| match h.GenPres.Pending |> Map.tryFind att |> Option.map _.Stage with | |
| | Some(AwaitingPatientData(ctx, uc, mail)) -> | |
| match ctx.Assertion.Patient with | |
| | None -> h, [] // cannot happen: ext 1a skipped this stage | |
| | Some p -> | |
| let data = | |
| match env.Msg with | |
| | PatientDataRead(_, d) -> Some d | |
| | _ -> None | |
| let pctx = { Patient = Some p; Data = data } | |
| { h with | |
| GenPres.Pending = | |
| h.GenPres.Pending |> Map.add att (pend h.Env.Now (AwaitingRecord(ctx, uc, mail, pctx))) }, | |
| [ send GenPresServer GenPresDatabase (ReadRecord(ForLaunch att, p)) ] | |
| | _ -> h, [] | |
| // Step 12. Rule 19 picks the TreatmentPlan the Session starts from: the most recent | |
| // that is either Signed, by whoever, or Unsigned and this User's own. Where | |
| // neither exists, the Session starts from nothing. Then Rule 7's other | |
| // Sessions, which the Server no longer mirrors and so must read (Rule 31). | |
| | GenPresDatabase, GenPresServer, RecordRead(ForLaunch att, record) -> | |
| match h.GenPres.Pending |> Map.tryFind att |> Option.map _.Stage with | |
| | Some(AwaitingRecord(ctx, uc, mail, pctx)) -> | |
| let start = record |> PatientRecord.startsFrom uc.UserId | |
| { h with | |
| GenPres.Pending = | |
| h.GenPres.Pending | |
| |> Map.add att (pend h.Env.Now (AwaitingPriors(ctx, uc, mail, pctx, start))) }, | |
| [ send GenPresServer GenPresDatabase (ReadSessionRecords(ForLaunch att)) ] | |
| | _ -> h, [] | |
| // Steps 13 and 14. Rule 7 closes this User's other Sessions, Rule 10 says so | |
| // once, and Rule 33 hands the Client the token it will return with every | |
| // create. From here the Server keeps nothing of the Session but its record. | |
| | GenPresDatabase, GenPresServer, SessionRecordsRead(ForLaunch att, others) -> | |
| match h.GenPres.Pending |> Map.tryFind att |> Option.map _.Stage with | |
| | Some(AwaitingPriors(ctx, uc, mail, pctx, start)) -> | |
| let h, out = openSession ctx.Client (Some ctx.Launch) (Some uc) (Some mail) pctx start others h | |
| { h with GenPres.Pending = h.GenPres.Pending |> Map.remove att }, out | |
| | _ -> h, [] | |
| // Rule 13 / UC-8. No launch, so no LaunchCredential, and GenPRES cannot know | |
| // who is at the keyboard. Neither the PatientRecord nor the | |
| // PatientDataPlatform is ever touched: with no PatientId there is nothing to | |
| // read. Rule 7 counts a User's Sessions and this one binds to none, so there | |
| // is nothing to close and no SessionRecords to read either. | |
| | GenPresClient _, GenPresServer, OpenAnonymous -> | |
| openSession env.From None None None { Patient = None; Data = None } None [] h | |
| // ══════════════════════════════════════════════════════════════════════ | |
| // Actor 4: the GenPRES Server, in Session — one request, several legs | |
| // ══════════════════════════════════════════════════════════════════════ | |
| // Rule 31 in one branch: a request arrives with everything it needs except who | |
| // sent it, and the answer to that is in the Database. Nothing about this | |
| // Session was in memory a moment ago, and nothing will be a moment after the | |
| // reply. Rule 8's refresh has one home, here, because every in-Session act | |
| // travels as this one message shape. | |
| | GenPresClient _, GenPresServer, SessionRequest(sid, cmd) -> | |
| let rid = RequestId h.GenPres.NextRequest | |
| let ctx = { Sid = sid; Client = env.From; Cmd = cmd; Stage = AwaitingSessionRecord } | |
| { h with GenPres.NextRequest = h.GenPres.NextRequest + 1 } |> putFlight rid ctx, | |
| [ send GenPresServer GenPresDatabase (ReadSessionRecord(ForRequest rid, sid)) ] | |
| // Rule 32: the User and the Patient of the request come from here. Rule 10: | |
| // where the Session is gone, this is the next opportunity to say so. | |
| | GenPresDatabase, GenPresServer, SessionRecordRead(ForRequest rid, record) -> | |
| match h.GenPres.InFlight |> Map.tryFind rid with | |
| | None -> h, [] | |
| | Some ctx -> | |
| match record with | |
| | None -> | |
| dropFlight rid h, [ send GenPresServer ctx.Client (SessionEnded None) ] | |
| | Some r when not (SessionRecord.isOpen r) -> | |
| if SessionRecord.tellsAtNextOpportunity r then | |
| let mark = match r.State with Ended(m, _) -> Some m | OpenOrGone -> None | |
| let told = r |> SessionRecord.told h.Env.Now | |
| dropFlight rid h, | |
| [ | |
| send GenPresServer GenPresDatabase (WriteSessionRecord told) | |
| send GenPresServer ctx.Client (SessionEnded mark) | |
| ] | |
| else | |
| // Told once, never twice: the request is still refused, but the | |
| // notice is not repeated. | |
| dropFlight rid h, [ send GenPresServer ctx.Client SessionRefused ] | |
| | Some r -> | |
| // Rule 8. Every request refreshes the idle clock, and the clock is | |
| // a field of the record now, so refreshing it is a write. | |
| let r = r |> SessionRecord.seen h.Env.Now | |
| let refreshed = send GenPresServer GenPresDatabase (WriteSessionRecord r) | |
| let h, out = dispatch rid ctx r h | |
| // CloseSession writes the ended record itself; anything else gets | |
| // the refresh. Writing both would be harmless but noisy. | |
| match ctx.Cmd with | |
| | CloseSession -> h, out | |
| | _ -> h, refreshed :: out | |
| // The PatientRecord came back for a request part-way through. | |
| | GenPresDatabase, GenPresServer, RecordRead(ForRequest rid, record) -> | |
| match h.GenPres.InFlight |> Map.tryFind rid with | |
| | Some({ Stage = AwaitingPatientRecord r } as ctx) -> | |
| match ctx.Cmd, r.User with | |
| // Rules 17 and 18. Opening the most recent Signed TreatmentPlan is also how | |
| // a blocked User gets unblocked: Rule 33's token is re-minted over it, | |
| // so it becomes the TreatmentPlan the Session opened with and Rule 20 no | |
| // longer bites. | |
| | OpenTreatmentPlan id, Some uc -> | |
| match record |> PatientRecord.mayOpen uc.UserId id with | |
| | Some s -> | |
| dropFlight rid h, | |
| [ send GenPresServer ctx.Client | |
| (TreatmentPlanOpened(s.Id, s.Orders, Token.mintOpened ctx.Sid r.Patient (Some s.Id))) ] | |
| | None -> | |
| dropFlight rid h, [ send GenPresServer ctx.Client NotPermitted ] | |
| | CreateTreatmentPlan _, Some _ -> decideCreate rid ctx r record h | |
| | _ -> dropFlight rid h, [] | |
| | _ -> h, [] | |
| // Rule 22: the Server is the only party that verifies a UserCredential, and | |
| // the PIN never leaves GenPRES. Rule 27: the count is per credential and | |
| // survives the Session, so guessing is capped outright rather than per visit. | |
| | GenPresDatabase, GenPresServer, CredentialRead(ForRequest rid, credential) -> | |
| match h.GenPres.InFlight |> Map.tryFind rid with | |
| | Some({ Stage = AwaitingUserCredential(r, pr, basePlan) } as ctx) -> | |
| match ctx.Cmd, r.User with | |
| | CreateTreatmentPlan(Some pin, orders, _, _, _), Some uc -> | |
| let before = credential |> Option.defaultValue (UserCredential.fresh uc.Login) | |
| let ok, after = UserCredential.verify pin before | |
| let writeBack = | |
| send GenPresServer GenPresDatabase (WriteCredential(ForRequest rid, after)) | |
| if ok then | |
| let h, out = appendDraft rid ctx r pr basePlan uc (Some pin) orders h | |
| h, writeBack :: out | |
| elif UserCredential.atLimit after then | |
| // Rule 9: a wrong entry at the limit ends the Session. Rule 10: | |
| // and this request is the opportunity to say so. | |
| let ended = | |
| r | |
| |> SessionRecord.endWith WrongPinLimit h.Env.Now | |
| |> SessionRecord.told h.Env.Now | |
| dropFlight rid h, | |
| [ | |
| writeBack | |
| send GenPresServer GenPresDatabase (WriteSessionRecord ended) | |
| send GenPresServer ctx.Client (SessionEnded(Some WrongPinLimit)) | |
| ] | |
| else | |
| dropFlight rid h, | |
| [ | |
| writeBack | |
| send GenPresServer ctx.Client (PinRejected(UserCredential.attemptsLeft after)) | |
| ] | |
| | _ -> dropFlight rid h, [] | |
| | _ -> h, [] | |
| // Rule 36 said yes. Rule 33: the Session now stands on what it just created, | |
| // so a fresh token goes back with the answer — Rules 20 and 21 are measured | |
| // from here on out against this TreatmentPlan. | |
| | GenPresDatabase, GenPresServer, TreatmentPlanAppended(ForRequest rid, plan) -> | |
| match h.GenPres.InFlight |> Map.tryFind rid with | |
| | Some({ Stage = AwaitingAppend r } as ctx) -> | |
| dropFlight rid h, | |
| [ send GenPresServer ctx.Client | |
| (TreatmentPlanCreated( | |
| plan.Id, | |
| plan.Signed, | |
| Token.mintOpened ctx.Sid r.Patient (Some plan.Id))) ] | |
| | _ -> h, [] | |
| // Rule 36 said no: somebody else's TreatmentPlan landed between this Server's | |
| // Rule 20 read and its append. The answer is the one the Client already | |
| // understands — take up their version (Rule 17). | |
| | GenPresDatabase, GenPresServer, AppendRefused(ForRequest rid, actual) -> | |
| match h.GenPres.InFlight |> Map.tryFind rid with | |
| | Some ctx -> dropFlight rid h, [ send GenPresServer ctx.Client (CreateBlocked actual) ] | |
| | None -> h, [] | |
| // UC-7 step 2. The PIN is gone; the UserCredential stays. Rule 26: mailed and | |
| // recorded, every removal as well as every setting — to the address on the | |
| // SessionRecord, since there is no Session in memory to hold one. | |
| | GenPresDatabase, GenPresServer, PinRemoved(ForRequest rid, c) -> | |
| match h.GenPres.InFlight |> Map.tryFind rid with | |
| | Some({ Stage = AwaitingPinRemoved r } as ctx) -> | |
| let (LoginName l) = c.Login | |
| dropFlight rid h, | |
| (pinChanged r.Mail $"PIN removed for %s{l}") | |
| @ [ send GenPresServer ctx.Client PinReset ] | |
| | _ -> h, [] | |
| // Written, and nothing more to say. | |
| | GenPresDatabase, GenPresServer, CredentialWritten _ | |
| | GenPresDatabase, GenPresServer, TreatmentPlanAppended _ | |
| | GenPresDatabase, GenPresServer, AppendRefused _ | |
| | GenPresDatabase, GenPresServer, PinRemoved _ | |
| | GenPresDatabase, GenPresServer, SessionRecordsRead _ | |
| | GenPresDatabase, GenPresServer, SessionRecordRead _ | |
| | GenPresDatabase, GenPresServer, RecordRead _ | |
| | GenPresDatabase, GenPresServer, CredentialRead _ -> h, [] | |
| // ══════════════════════════════════════════════════════════════════════ | |
| // Actor 3: the GenPRES Client — and the cart, which lives here (Rule 31) | |
| // ══════════════════════════════════════════════════════════════════════ | |
| // A closed browser is not there any more. Nothing it might have sent reaches | |
| // the Server (UC-12 ext 2a), which is exactly why no close can be inferred — | |
| // and the cart went with it, because the cart was only ever here. | |
| | _, GenPresClient b, _ when | |
| h.Clients |> Map.tryFind b |> Option.map _.Closed |> Option.defaultValue false -> | |
| h, [] | |
| // Consequence 4: the credential travels in a URL, so it lands in the address | |
| // bar — and stays there, which is what makes a refresh a retry. | |
| | MainEhrLaunchScript, GenPresClient b, OpenUrl cred -> | |
| h |> onClient b (fun s -> { s with UrlCredential = Some cred }), | |
| [ send (GenPresClient b) GenPresServer (RedeemLaunch cred) ] | |
| // F5. The address bar is the Client's only memory of the launch, so this is | |
| // the one branch that reads UrlCredential rather than merely clearing it. | |
| | User, GenPresClient b, Refresh -> | |
| match h.Clients |> Map.tryFind b |> Option.bind _.UrlCredential with | |
| | Some cred -> h, [ send (GenPresClient b) GenPresServer (RedeemLaunch cred) ] | |
| | None -> h, [] | |
| // UC-8. The Client has no LaunchCredential to present, and asks for a Session | |
| // without one. | |
| | User, GenPresClient b, OpenDirectly -> | |
| h |> onClient b (fun s -> { s with UrlCredential = None }), | |
| [ send (GenPresClient b) GenPresServer OpenAnonymous ] | |
| // Rule 6 / UC-1 ext 9a. The offer carries nothing over from the launch: no | |
| // User, no Patient. It is only made where relaunching would not cure the | |
| // failure — an unrecognised login, or an unreachable registry. | |
| | User, GenPresClient b, AcceptAnonymousOffer -> | |
| match h.Clients |> Map.tryFind b with | |
| | Some s when s.AnonymousOffer -> | |
| h |> onClient b (fun s -> { s with AnonymousOffer = false; Showing = None }), | |
| [ send (GenPresClient b) GenPresServer OpenAnonymous ] | |
| | _ -> h, [] | |
| // UC-2 step 3. Nothing else was on offer until this was answered. | |
| | User, GenPresClient b, ChoosePin pin -> | |
| match h.Clients |> Map.tryFind b |> Option.bind _.AwaitingPin with | |
| | Some att -> | |
| h |> onClient b (fun s -> { s with AwaitingPin = None; Showing = None }), | |
| [ send (GenPresClient b) GenPresServer (SupplyPin(att, pin)) ] | |
| | None -> h, [] | |
| // Concept 15 and Rule 31: prescribing changes the Client's own cart, and the | |
| // whole of it then travels — to be computed on, or to be saved. Rule 11: the | |
| // SessionId rides in the request, never in a URL, and it is also what | |
| // refreshes the idle clock. | |
| | User, GenPresClient b, Act a -> | |
| let st = clientState b h | |
| let toServer cmd = [ send (GenPresClient b) GenPresServer (SessionRequest(st.Sid.Value, cmd)) ] | |
| match st.Sid with | |
| | None -> h, [] | |
| | Some _ -> | |
| match a with | |
| | Prescribes id -> | |
| // Whatever the User just did to it. Content is opaque here; what | |
| // matters is that it differs from the base, which is how the | |
| // Server can tell changed from unchanged (Rule 35). | |
| let content = $"v%i{h.Env.Now}" | |
| let working = | |
| if st.Working |> List.exists (fun o -> o.Id = id) then | |
| st.Working | |
| |> List.map (fun o -> if o.Id = id then { o with Content = content } else o) | |
| else | |
| st.Working | |
| @ [ { Id = id; Patient = st.Patient; Content = content; Stamp = None } ] | |
| h |> onClient b (fun s -> { s with Working = working }), toServer (Compute working) | |
| | EntersPatientData d -> | |
| // Concept 2: the User can always supply the data by hand — with a | |
| // Patient or without one (Rule 12). | |
| h |> onClient b (fun s -> { s with Data = Some d }), toServer (Compute st.Working) | |
| | Saves -> | |
| match st.Opened with | |
| | Some tok -> h, toServer (CreateTreatmentPlan(None, st.Working, st.Data, tok, st.Notice)) | |
| | None -> h, [] // Rule 33: the Client cannot make one | |
| | Signs pin -> | |
| match st.Opened with | |
| | Some tok -> h, toServer (CreateTreatmentPlan(Some pin, st.Working, st.Data, tok, st.Notice)) | |
| | None -> h, [] | |
| | OpensTreatmentPlan id -> h, toServer (OpenTreatmentPlan id) | |
| | AsksPinReset -> h, toServer ResetPin | |
| | ClosesSession -> | |
| // UC-12 ext 1a: the Client can warn that unsaved changes are about | |
| // to be discarded, but closed is closed. They existed only here | |
| // (Rule 31), so closing is what discards them. | |
| h |> onClient b (fun s -> | |
| { s with Working = []; Data = None; Opened = None; Notice = None }), | |
| toServer CloseSession | |
| | CarriesOverFrom src -> | |
| // UC-9 step 5. The unsaved work outlived its Session because it | |
| // was never in the Server (Rule 31). It comes into this one as | |
| // fresh prescribing (Concept 15) — not as a resumed Session, and | |
| // with no claim on the old one's stamps: Rule 35 will decide those. | |
| let source = clientState src h | |
| let carried = | |
| source.Working | |
| |> List.filter (fun o -> st.Working |> List.forall (fun x -> x.Id <> o.Id)) | |
| |> List.map (fun o -> { o with Patient = st.Patient; Stamp = None }) | |
| let working = st.Working @ carried | |
| h |> onClient b (fun s -> { s with Working = working }), toServer (Compute working) | |
| | User, GenPresClient b, CloseBrowser -> | |
| // UC-12 ext 2a: nothing reaches the Server. A vanished browser is | |
| // indistinguishable from a silent one, so the Session is left to idle out | |
| // — and the cart is gone, because it was only ever here (Rule 31). | |
| h |> onClient b (fun s -> | |
| { s with | |
| Closed = true | |
| Working = [] | |
| Data = None | |
| Opened = None | |
| Notice = None }), [] | |
| | GenPresServer, GenPresClient b, SessionOpened(sid, _, user, pctx, orders, token) -> | |
| h |> onClient b (fun s -> | |
| { s with | |
| UrlCredential = None | |
| AwaitingPin = None | |
| AnonymousOffer = false | |
| Sid = Some sid | |
| User = user | |
| Patient = pctx.Patient | |
| Data = pctx.Data | |
| Working = orders | |
| Opened = Some token | |
| Notice = None }), [] | |
| | GenPresServer, GenPresClient b, PinRequired att -> | |
| h |> onClient b (fun s -> | |
| { s with | |
| AwaitingPin = Some att | |
| Showing = Some "choose a PIN — nothing else is offered until you do" }), [] | |
| | GenPresServer, GenPresClient b, LaunchRefused -> | |
| // ext 8a: relaunching cures this, so relaunching is what is offered. | |
| h |> onClient b (fun s -> | |
| { s with | |
| UrlCredential = None | |
| Showing = Some "the launch failed — relaunch from MainEHR" }), [] | |
| | GenPresServer, GenPresClient b, NotAuthorised -> | |
| // ext 9a: relaunching would not help, so the anonymous open is the only | |
| // offer worth making (Rule 6). | |
| h |> onClient b (fun s -> | |
| { s with | |
| UrlCredential = None | |
| AnonymousOffer = true | |
| Showing = Some "not authorised — continue anonymously?" }), [] | |
| // The registry being down is transient, so a relaunch — which mints a fresh | |
| // credential, the one thing F5 cannot do once this one is spent — plausibly | |
| // cures it. Both offers stand. Contrast NotAuthorised above, where the answer | |
| // will be the same however often it is asked. | |
| | GenPresServer, GenPresClient b, AuthorityUnavailable -> | |
| h |> onClient b (fun s -> | |
| { s with | |
| UrlCredential = None | |
| AnonymousOffer = true | |
| Showing = | |
| Some "authorisation could not be checked — relaunch from MainEHR, or continue anonymously?" }), [] | |
| // Consequence 1: no Client at all is served when the Server is down, so in | |
| // practice the User sees the browser's own error page. Where a Client was | |
| // already served, the credential stays in the address bar and a refresh | |
| // retries — for as long as Rule 3 allows. The cart stays too (Rule 31): a | |
| // Server that is down has not ended anything (Rule 9). | |
| | GenPresServer, GenPresClient b, ServerUnreachable -> | |
| h |> onClient b (fun s -> { s with Showing = Some "GenPRES is unavailable" }), [] | |
| // The Session is gone; the work is not. It was never in the Server, so the | |
| // Client still holds it and may offer to carry it into the next Session as | |
| // fresh prescribing (Concept 15; UC-9 step 5). | |
| | GenPresServer, GenPresClient b, SessionEnded mark -> | |
| let text = | |
| match mark with | |
| | Some m -> $"the session ended: %A{m} — relaunch from MainEHR" | |
| | None -> "no such session — relaunch from MainEHR" | |
| h |> onClient b (fun s -> { s with Sid = None; Opened = None; Showing = Some text }), [] | |
| | GenPresServer, GenPresClient b, SessionRefused -> | |
| h |> onClient b (fun s -> { s with Sid = None; Opened = None }), [] | |
| | GenPresServer, GenPresClient b, PriorSessionNotice _ -> | |
| h |> onClient b (fun s -> | |
| { s with Showing = Some "work in an earlier session may have been lost" }), [] | |
| // Rule 31: the answer comes back from the payload, and the Client keeps it — | |
| // because the Client is the only party that keeps anything. | |
| | GenPresServer, GenPresClient b, Computed orders -> | |
| h |> onClient b (fun s -> { s with Working = orders }), [] | |
| | GenPresServer, GenPresClient b, CreateBlocked _ -> | |
| h |> onClient b (fun s -> | |
| { s with Showing = Some "someone signed since you opened — take up their version" }), [] | |
| // Rule 34. The token is what a choice to create anyway must return, so the | |
| // Client keeps it: proceeding is re-sending the create, holding off is not. | |
| | GenPresServer, GenPresClient b, UnsignedWorkNotice(uc, token) -> | |
| let (LoginName l) = uc.Login | |
| h |> onClient b (fun s -> | |
| { s with | |
| Notice = Some token | |
| Showing = Some $"unsigned work of %s{l} is newer than yours — create anyway?" }), [] | |
| | GenPresServer, GenPresClient b, CreateRefused why -> | |
| h |> onClient b (fun s -> { s with Showing = Some $"the save was refused: %s{why}" }), [] | |
| // Rules 33 and 34. The baseline moved, so the old token is spent and a new one | |
| // arrives with the answer; the notice, having been acted on, is spent too. | |
| | GenPresServer, GenPresClient b, TreatmentPlanCreated(_, _, token) -> | |
| h |> onClient b (fun s -> { s with Opened = Some token; Notice = None }), [] | |
| | GenPresServer, GenPresClient b, TreatmentPlanOpened(_, orders, token) -> | |
| h |> onClient b (fun s -> | |
| { s with Working = orders; Opened = Some token; Notice = None }), [] | |
| | GenPresServer, GenPresClient b, PinRejected left -> | |
| h |> onClient b (fun s -> { s with Showing = Some $"wrong PIN — %i{left} left" }), [] | |
| | GenPresServer, GenPresClient b, NoTreatmentPlanHere -> | |
| h |> onClient b (fun s -> { s with Showing = Some "no patient: nothing can be saved" }), [] | |
| | GenPresServer, GenPresClient b, NotPermitted -> | |
| h |> onClient b (fun s -> { s with Showing = Some "not permitted" }), [] | |
| | GenPresServer, GenPresClient b, PinReset -> | |
| h |> onClient b (fun s -> | |
| { s with Showing = Some "PIN removed — relaunch to set a new one" }), [] | |
| // ── anything else ── | |
| // An envelope an edge permits but the recipient does not accept. Recorded | |
| // rather than swallowed, so a misrouted or forged message shows in the trace. | |
| | _ -> h, [ send env.To Environment (Refused env) ] | |
| /// The edge table is enforced here, before anything is delivered: an envelope no | |
| /// edge permits never reaches its recipient at all. That is what separates the | |
| /// Constraints section from a convention — no component can relay on another's | |
| /// behalf, even by accident, because the wire does not exist. | |
| /// | |
| /// `depthFirst` is the scheduler. Depth first — a cascade runs to the end before | |
| /// the next thing in the inbox starts — is the readable default and what every | |
| /// scenario but one uses. Breadth first interleaves the cascades leg by leg, which | |
| /// is the only way to put two creates in flight at once and so the only way to | |
| /// exercise Rule 36. It is the same messages either way; only the order differs, | |
| /// which is precisely what Rule 36 exists to be safe against. | |
| let runWith depthFirst fuel hospital inbox = | |
| let rec loop fuel h trace queue = | |
| match queue with | |
| | [] -> h, List.rev trace, "completed" | |
| | _ when fuel <= 0 -> h, List.rev trace, "exhausted" | |
| | env :: rest -> | |
| if Edges.permits env.From env.To then | |
| let h, out = update h env | |
| let next = if depthFirst then out @ rest else rest @ out | |
| loop (fuel - 1) h (env :: trace) next | |
| else | |
| let refusal = { From = env.To; To = Environment; Msg = Refused env } | |
| let h, _ = update h refusal | |
| loop (fuel - 1) h (refusal :: env :: trace) rest | |
| loop fuel hospital [] inbox | |
| let run fuel hospital inbox = runWith true fuel hospital inbox | |
| // ───────────────────────────── printing ───────────────────────────── | |
| /// Rendering an envelope for the trace. Formatting only: no branch here decides | |
| /// anything, so a message may be added without touching the model. | |
| module Envelope = | |
| let actorName = | |
| function | |
| | User -> "User" | |
| | MainEhrWorkstation -> "Workstation" | |
| | MainEhrLaunchScript -> "LaunchScript" | |
| | GenPresClient(BrowserId i) -> $"Client%i{i}" | |
| | GenPresServer -> "Server" | |
| | GenPresDatabase -> "Database" | |
| | PatientDataPlatform -> "Platform" | |
| | Broker -> "Broker" | |
| | UserRegistry -> "Registry" | |
| | MailService -> "Mail" | |
| | Environment -> "Env" | |
| let private tagName = | |
| function | |
| | ForLaunch(AttemptId a) -> $"#%i{a}" | |
| | ForRequest(RequestId r) -> $"req-%i{r}" | |
| | ForSweep -> "sweep" | |
| let private planName = function Some(TreatmentPlanId s) -> s | None -> "(nothing)" | |
| let private cmdName = | |
| function | |
| | Compute os -> $"Compute (%i{os.Length} order contexts)" | |
| | CreateTreatmentPlan(pin, os, _, tok, notice) -> | |
| let what = match pin with Some(Pin p) -> $"Sign (pin %s{p})" | None -> "Save" | |
| let n = match notice with Some _ -> " +notice" | None -> "" | |
| $"%s{what} (%i{os.Length} order contexts, opened-with %s{planName tok.Plan}%s{n})" | |
| | OpenTreatmentPlan(TreatmentPlanId s) -> $"OpenTreatmentPlan %s{s}" | |
| | ResetPin -> "ResetPin" | |
| | CloseSession -> "CloseSession" | |
| let private actName = | |
| function | |
| | Prescribes(OrderContextId o) -> $"Prescribes %s{o}" | |
| | EntersPatientData(PatientData d) -> $"EntersPatientData \"%s{d}\"" | |
| | Saves -> "Saves" | |
| | Signs(Pin p) -> $"Signs (pin %s{p})" | |
| | OpensTreatmentPlan(TreatmentPlanId s) -> $"OpensTreatmentPlan %s{s}" | |
| | AsksPinReset -> "AsksPinReset" | |
| | ClosesSession -> "ClosesSession" | |
| | CarriesOverFrom(BrowserId b) -> $"CarriesOverFrom Client%i{b}" | |
| let rec describe (m: Msg) = | |
| match m with | |
| | Tick -> "Tick" | |
| | Start a -> $"Start %s{actorName a}" | |
| | Stop a -> $"Stop %s{actorName a}" | |
| | LogIn(LoginName u) -> $"LogIn %s{u}" | |
| | SelectPatient(PatientId p) -> $"SelectPatient %s{p}" | |
| | ClearPatient -> "ClearPatient" | |
| | TriggerLaunch -> "TriggerLaunch" | |
| | LaunchError e -> $"LaunchError \"%s{e}\"" | |
| | PrepareLaunch(LoginName u, p) -> | |
| let pat = match p with Some(PatientId x) -> x | None -> "(no patient)" | |
| $"PrepareLaunch %s{u} %s{pat}" | |
| | LaunchPrepared(LaunchCredential c) -> $"LaunchPrepared %s{c}" | |
| | LaunchNotPrepared -> "LaunchNotPrepared" | |
| | OpenUrl(LaunchCredential c) -> $"GET /genpres?launch=%s{c}" | |
| | Refresh -> "F5" | |
| | OpenDirectly -> "OpenDirectly" | |
| | AcceptAnonymousOffer -> "AcceptAnonymousOffer" | |
| | ChoosePin(Pin p) -> $"ChoosePin %s{p}" | |
| | Act a -> actName a | |
| | CloseBrowser -> "CloseBrowser" | |
| | RedeemLaunch(LaunchCredential c) -> $"RedeemLaunch %s{c}" | |
| | OpenAnonymous -> "OpenAnonymous" | |
| | SupplyPin(AttemptId a, Pin p) -> $"SupplyPin #%i{a} %s{p}" | |
| | SessionRequest(SessionId s, c) -> $"%s{s}: %s{cmdName c}" | |
| | ResolveLaunch(AttemptId a, LaunchCredential c) -> $"ResolveLaunch #%i{a} %s{c}" | |
| | LaunchResolved(AttemptId a, LaunchNo n, x) -> | |
| let (LoginName u) = x.Login | |
| let pat = match x.Patient with Some(PatientId p) -> p | None -> "(no patient)" | |
| $"LaunchResolved #%i{a} launch-%03i{n} -> %s{u} / %s{pat} (a login and a patient: no identity, no role)" | |
| | LaunchRejected(AttemptId a, no, f) -> | |
| let tag = match no with Some(LaunchNo n) -> $"launch-%03i{n}" | None -> "launch-???" | |
| $"LaunchRejected #%i{a} %s{tag} %A{f}" | |
| | ResolveUser(AttemptId a, LoginName u) -> $"ResolveUser #%i{a} %s{u}" | |
| | UserResolved(AttemptId a, uc, _) -> | |
| let (UserId u) = uc.UserId | |
| $"UserResolved #%i{a} %s{u} %A{uc.Role}" | |
| | UserUnresolved(AttemptId a, f) -> $"UserUnresolved #%i{a} %A{f}" | |
| | ReadPatientData(AttemptId a, PatientId p) -> $"ReadPatientData #%i{a} %s{p}" | |
| | PatientDataRead(AttemptId a, _) -> $"PatientDataRead #%i{a}" | |
| | PatientDataUnavailable(AttemptId a) -> $"PatientDataUnavailable #%i{a}" | |
| | ReadCredential(t, LoginName u) -> $"ReadCredential %s{tagName t} %s{u}" | |
| | CredentialRead(t, c) -> | |
| let pin = match c |> Option.bind _.Pin with Some _ -> "pin set" | None -> "no pin" | |
| $"CredentialRead %s{tagName t} (%s{pin})" | |
| | WriteCredential(t, _) -> $"WriteCredential %s{tagName t}" | |
| | CredentialWritten(t, _) -> $"CredentialWritten %s{tagName t}" | |
| | RemovePin(t, LoginName u) -> $"RemovePin %s{tagName t} %s{u}" | |
| | PinRemoved(t, _) -> $"PinRemoved %s{tagName t}" | |
| | ReadRecord(t, PatientId p) -> $"ReadRecord %s{tagName t} %s{p}" | |
| | RecordRead(t, r) -> $"RecordRead %s{tagName t} (%i{r.Plans.Length} plans)" | |
| | AppendIf(t, expected, d) -> | |
| $"""AppendIf %s{tagName t} head=%s{planName expected} %s{if d.Signed then "Signed" else "Unsigned"}""" | |
| | TreatmentPlanAppended(_, s) -> let (TreatmentPlanId i) = s.Id in $"TreatmentPlanAppended %s{i}" | |
| | AppendRefused(t, TreatmentPlanId a) -> $"AppendRefused %s{tagName t} head is now %s{a}" | |
| | WriteSessionRecord r -> | |
| let (SessionNo n) = r.No | |
| $"WriteSessionRecord ses-%03i{n} %A{r.State}" | |
| | ReadSessionRecord(t, SessionId s) -> $"ReadSessionRecord %s{tagName t} %s{s}" | |
| | SessionRecordRead(t, r) -> | |
| let what = match r with Some x -> $"%A{x.State}" | None -> "(no such session)" | |
| $"SessionRecordRead %s{tagName t} %s{what}" | |
| | ReadSessionRecords t -> $"ReadSessionRecords %s{tagName t}" | |
| | SessionRecordsRead(t, rs) -> $"SessionRecordsRead %s{tagName t} (%i{rs.Length})" | |
| | SendMail(MailAddress a, what) -> $"SendMail {a}: \"%s{what}\"" | |
| | SessionOpened(SessionId s, SessionNo n, u, p, os, tok) -> | |
| let who = | |
| match u with | |
| | Some uc -> let (LoginName l) = uc.Login in $"%s{l}/%A{uc.Role}" | |
| | None -> "anonymous" | |
| let pat = match p.Patient with Some(PatientId x) -> x | None -> "(no patient)" | |
| $"SessionOpened %s{s} ses-%03i{n} %s{who} %s{pat} (%i{os.Length} order contexts, opened-with %s{planName tok.Plan})" | |
| | PinRequired(AttemptId a) -> $"PinRequired #%i{a}" | |
| | LaunchRefused -> "LaunchRefused" | |
| | NotAuthorised -> "NotAuthorised" | |
| | AuthorityUnavailable -> "AuthorityUnavailable" | |
| | ServerUnreachable -> "ServerUnreachable" | |
| | SessionEnded m -> $"SessionEnded %A{m}" | |
| | SessionRefused -> "SessionRefused" | |
| | PriorSessionNotice ss -> | |
| let names = | |
| ss |> List.map (fun (SessionNo i, m) -> $"ses-%03i{i}=%A{m}") |> String.concat ", " | |
| $"PriorSessionNotice [%s{names}]" | |
| | Computed os -> $"Computed (%i{os.Length} order contexts)" | |
| | CreateBlocked(TreatmentPlanId s) -> $"CreateBlocked by %s{s}" | |
| | UnsignedWorkNotice(uc, t) -> | |
| let (LoginName l) = uc.Login | |
| $"UnsignedWorkNotice (%s{l}, disclosing %i{t.Disclosed.Length})" | |
| | CreateRefused why -> $"CreateRefused \"%s{why}\"" | |
| | TreatmentPlanCreated(TreatmentPlanId s, signed, _) -> | |
| $"""TreatmentPlanCreated %s{s} %s{if signed then "Signed" else "Unsigned"}""" | |
| | TreatmentPlanOpened(TreatmentPlanId s, os, _) -> $"TreatmentPlanOpened %s{s} (%i{os.Length} order contexts)" | |
| | PinRejected n -> $"PinRejected (%i{n} left)" | |
| | NoTreatmentPlanHere -> "NoTreatmentPlanHere" | |
| | NotPermitted -> "NotPermitted" | |
| | PinReset -> "PinReset" | |
| | Noted what -> $"Noted \"%s{what}\"" | |
| | Refused e -> | |
| $"REFUSED << %s{actorName e.From} -> %s{actorName e.To} %s{describe e.Msg} >>" | |
| let show (env: Envelope) = | |
| $" %-12s{actorName env.From} -> %-12s{actorName env.To} %s{describe env.Msg}" | |
| /// The idle sweep runs on every Tick and says nothing most of the time. Keeping it | |
| /// out of the printed trace is formatting, not filtering: the envelopes are all | |
| /// still in `lastTrace`, and the assertions see them. | |
| let noise (env: Envelope) = | |
| match env.Msg with | |
| | Tick -> true | |
| | ReadSessionRecords ForSweep | |
| | SessionRecordsRead(ForSweep, _) -> true | |
| | _ -> false | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // 3. SCENARIOS AND ASSERTIONS | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // ───────────────────────────── what the world does ───────────────────────────── | |
| let atWorkstation msg = { From = User; To = MainEhrWorkstation; Msg = msg } | |
| let triggerLaunch = { From = User; To = MainEhrLaunchScript; Msg = TriggerLaunch } | |
| let envt to_ msg = { From = Environment; To = to_; Msg = msg } | |
| let tick = envt Environment Tick | |
| let ticks n = List.replicate n tick | |
| let atClient b msg = { From = User; To = GenPresClient(BrowserId b); Msg = msg } | |
| let act b a = atClient b (Act a) | |
| /// UC-1 steps 1 and 2. `None` is ext 1a: no Patient is active in the MainEHR Session. | |
| let launchAs (LoginName login) (patient: PatientId option) = | |
| [ | |
| atWorkstation (LogIn(LoginName login)) | |
| match patient with | |
| | Some p -> atWorkstation (SelectPatient p) | |
| | None -> atWorkstation ClearPatient | |
| triggerLaunch | |
| ] | |
| // ───────────────────────────── the Cast ───────────────────────────── | |
| // The document's Cast. Their state is the state before the first use case runs; | |
| // later use cases inherit whatever earlier ones left behind. | |
| // | |
| // One Workstation stands in for many. Nothing in the Rules distinguishes them: | |
| // Rule 7 counts a User's Sessions, not a workstation's, and Invariant 1 is about one | |
| // MainEHR Session. So "User B at their own workstation" is modelled by User B | |
| // logging in — User A's GenPRES Session is untouched either way. | |
| let ucA = { UserId = UserId "u-a"; Login = LoginName "dr.a"; Role = Prescriber } | |
| let ucB = { UserId = UserId "u-b"; Login = LoginName "dr.b"; Role = Prescriber } | |
| let ucC = { UserId = UserId "u-c"; Login = LoginName "nurse.c"; Role = Reader } | |
| let mailA = MailAddress "a@hospital" | |
| let mailB = MailAddress "b@hospital" | |
| let mailC = MailAddress "c@hospital" | |
| let pinA = Pin "1111" | |
| let pinB = Pin "2222" | |
| let pat1 = PatientId "pat-1" // no GenPRES PatientRecord yet | |
| let pat2 = PatientId "pat-2" // head is a Signed TreatmentPlan | |
| let pat3 = PatientId "pat-3" // head is an Unsigned TreatmentPlan of A's, over a Signed one | |
| let oc id pat by = | |
| { Id = OrderContextId id; Patient = Some pat; Content = $"%s{id}/as-saved"; Stamp = Some by } | |
| let mkPlan n patient by signed baseOn orders = | |
| { | |
| Id = TreatmentPlanId $"plan-%04i{n}" | |
| No = TreatmentPlanNo n | |
| Patient = patient | |
| By = by | |
| Base = baseOn | |
| Orders = orders | |
| Signed = signed | |
| At = 0 | |
| } | |
| let p2Signed = mkPlan 1 pat2 ucA true None [ oc "oc-1" pat2 ucA ] | |
| let p3Signed = mkPlan 2 pat3 ucB true None [ oc "oc-2" pat3 ucB ] | |
| let p3Unsigned = mkPlan 3 pat3 ucA false (Some p3Signed.Id) [ oc "oc-2" pat3 ucB; oc "oc-3" pat3 ucA ] | |
| /// The world the Cast starts in. | |
| let world = | |
| let h = Hospital.empty | |
| let h = | |
| { h with | |
| Registry.Users = | |
| Map.ofList [ | |
| ucA.Login, (ucA, mailA) | |
| ucB.Login, (ucB, mailB) | |
| ucC.Login, (ucC, mailC) | |
| ] } | |
| let h = | |
| { h with | |
| Platform.Data = | |
| Map.ofList [ | |
| pat1, PatientData "pat-1: 4y, 17kg" | |
| pat2, PatientData "pat-2: 7y, 24kg" | |
| pat3, PatientData "pat-3: 1y, 9kg" | |
| ] } | |
| let h = | |
| { h with | |
| Database.Credentials = | |
| Map.ofList [ | |
| ucA.Login, { Login = ucA.Login; Pin = Some pinA; AttemptCount = 0 } | |
| ucB.Login, { Login = ucB.Login; Pin = Some pinB; AttemptCount = 0 } | |
| ] } | |
| let h = | |
| { h with | |
| Database.Records = | |
| Map.ofList [ | |
| pat2, { Patient = pat2; Plans = [ p2Signed ] } | |
| pat3, { Patient = pat3; Plans = [ p3Unsigned; p3Signed ] } | |
| ] } | |
| // The Cast's TreatmentPlans occupy plan-0001 to plan-0003, so the Database mints from | |
| // above them. Ids are never reissued. | |
| { h with Database.NextPlan = 10 } | |
| // ───────────────────────────── assertions ───────────────────────────── | |
| let mutable lastTrace : Envelope list = [] | |
| /// Every envelope of every scenario, so the Consequences and Guarantees can be | |
| /// checked over the whole run rather than one step of it. | |
| let mutable allTrace : Envelope list = [] | |
| let mutable checks = 0 | |
| let mutable failures = 0 | |
| /// Rule 31, structurally: after every scenario step, is the Server empty of requests? | |
| let mutable everCarriedARequest = false | |
| let expect label cond = | |
| checks <- checks + 1 | |
| if cond then printfn $" [ok] {label}" | |
| else | |
| failures <- failures + 1 | |
| printfn $" [FAIL] {label}" | |
| let saw (p: Msg -> bool) = lastTrace |> List.exists (fun e -> p e.Msg) | |
| let never (p: Msg -> bool) = not (saw p) | |
| let sawTo actor (p: Msg -> bool) = | |
| lastTrace |> List.exists (fun e -> e.To = actor && p e.Msg) | |
| let countOf (p: Msg -> bool) = lastTrace |> List.filter (fun e -> p e.Msg) |> List.length | |
| /// Did `first` happen before `second` in the trace? Used where the document fixes an | |
| /// order — Rule 24, and UC-3 ext 3c. | |
| let before (first: Msg -> bool) (second: Msg -> bool) = | |
| let idx p = lastTrace |> List.tryFindIndex (fun e -> p e.Msg) | |
| match idx first, idx second with | |
| | Some a, Some b -> a < b | |
| | _ -> false | |
| // ───────────────────────────── reading the world ───────────────────────────── | |
| // Everything a Session is, is in the Database now (Rule 31), and everything it is | |
| // working on is in a Client. There is no third place to look. | |
| let recNo n (h: Hospital) = h.Database.Sessions |> List.tryFind (fun r -> r.No = SessionNo n) | |
| let stateOf n h = recNo n h |> Option.map _.State | |
| let noticeOf n (h: Hospital) = recNo n h |> Option.map _.Notice | |
| let wasTold n h = match noticeOf n h with Some(Told _) -> true | _ -> false | |
| let lastSeenOf n (h: Hospital) = recNo n h |> Option.map _.LastSeen | |
| /// Newest first: `WriteSessionRecord` puts a new record on the front. | |
| let newestRecord (h: Hospital) = h.Database.Sessions |> List.tryHead | |
| let openRecords (h: Hospital) = h.Database.Sessions |> List.filter SessionRecord.isOpen | |
| let openCount h = (openRecords h).Length | |
| let recordCount (h: Hospital) = h.Database.Sessions.Length | |
| let openOfUser (uc: UserContext) h = | |
| openRecords h |> List.filter (fun r -> SessionRecord.userId r = Some uc.UserId) | |
| let recordFor p (h: Hospital) = | |
| h.Database.Records |> Map.tryFind p |> Option.defaultValue (PatientRecord.empty p) | |
| let headOf p h = (recordFor p h).Plans |> List.tryHead | |
| let planCount p h = (recordFor p h).Plans.Length | |
| let lastTab (h: Hospital) = h.Workstation.NextTab - 1 | |
| let clientOf b (h: Hospital) = h.Clients |> Map.tryFind (BrowserId b) | |
| let showingOf b h = clientOf b h |> Option.bind _.Showing | |
| let sidAt b h = clientOf b h |> Option.bind _.Sid | |
| let userAt b h = clientOf b h |> Option.bind _.User | |
| let patientAt b h = clientOf b h |> Option.bind _.Patient | |
| let dataAt b h = clientOf b h |> Option.bind _.Data | |
| let workingAt b h = clientOf b h |> Option.map _.Working |> Option.defaultValue [] | |
| /// Rule 33: the TreatmentPlan the Session opened with, as the Client holds it. | |
| let openedAt b h = clientOf b h |> Option.bind _.Opened |> Option.bind _.Plan | |
| let noticeAt b h = clientOf b h |> Option.bind _.Notice | |
| let mailsTo (addr: MailAddress) (h: Hospital) = h.Mail |> List.filter (fst >> (=) addr) | |
| let credentialOf (uc: UserContext) (h: Hospital) = h.Database.Credentials |> Map.tryFind uc.Login | |
| // ───────────────────────────── running a scenario ───────────────────────────── | |
| /// One PatientRecord as a chain, oldest first, each link `id/who/Signed-or-Unsigned`. | |
| /// The arrow is "and then", not "became": a PatientRecord is append-only (Concept 12), | |
| /// so every link is still there and each stands on the one to its left. | |
| let private planChain (r: PatientRecord) = | |
| r.Plans | |
| |> List.rev | |
| |> List.map (fun s -> | |
| let (TreatmentPlanId i) = s.Id | |
| let (LoginName l) = s.By.Login | |
| $"""%s{i}/%s{l}/%s{if s.Signed then "S" else "U"}""") | |
| |> String.concat " -> " | |
| let private planChains (h: Hospital) = | |
| h.Database.Records | |
| |> Map.toList | |
| |> List.map (fun (PatientId p, r) -> p, planChain r) | |
| |> List.filter (snd >> (<>) "") | |
| /// What the Patients' treatment plans looked like going in. Printed above the trace so | |
| /// that the state printed below it can be read as a difference rather than as a fact: | |
| /// a scenario touches one Patient, and without the baseline the others read as though | |
| /// they had moved too. | |
| let plansBefore (h: Hospital) = | |
| printfn " treatment plans before (oldest first, -> = and then):" | |
| for p, chain in planChains h do | |
| printfn $" %s{p}: %s{chain}" | |
| let dump (before: Hospital) (h: Hospital) = | |
| printfn $" now=%i{h.Env.Now} open=%i{openCount h} in-flight=%i{h.GenPres.InFlight.Count} launches=%i{h.GenPres.Pending.Count}" | |
| h.Database.Sessions | |
| |> List.rev | |
| |> List.iter (fun r -> | |
| let (SessionNo n) = r.No | |
| let who = | |
| match r.User with | |
| | Some uc -> let (LoginName l) = uc.Login in l | |
| | None -> "anonymous" | |
| let pat = match r.Patient with Some(PatientId p) -> p | None -> "(no patient)" | |
| let told = | |
| match r.Notice with | |
| | Told at -> $" told={at}" | |
| | Owed -> " owed a notice" | |
| | NotOwed -> "" | |
| printfn $" ses-%03i{n} %-10s{who} %-11s{pat} %A{r.State}%s{told}") | |
| let was = planChains before |> Map.ofList | |
| printfn " treatment plans after:" | |
| for p, chain in planChains h do | |
| let mark = if was.TryFind p = Some chain then " (unchanged)" else " (appended)" | |
| printfn $" %s{p}: %s{chain}%s{mark}" | |
| h.Clients | |
| |> Map.iter (fun (BrowserId b) x -> | |
| let cart = if x.Working.IsEmpty then "" else $" cart=%i{x.Working.Length}" | |
| match x.Showing with | |
| | Some n -> printfn $" Client%i{b}: %s{n}%s{cart}" | |
| | None -> if cart <> "" then printfn $" Client%i{b}:%s{cart}") | |
| /// Rule 31, checked after every step: whatever the Server was doing, it is not doing | |
| /// it any more, and it kept nothing. | |
| let private noteFlight (h: Hospital) = | |
| if not h.GenPres.InFlight.IsEmpty then everCarriedARequest <- true | |
| let step label h inbox = | |
| printfn "" | |
| printfn $"== {label} ==" | |
| plansBefore h | |
| let after, trace, outcome = Hospital.run 4000 h inbox | |
| lastTrace <- trace | |
| allTrace <- allTrace @ trace | |
| trace |> List.filter (Envelope.noise >> not) |> List.iter (Envelope.show >> printfn "%s") | |
| if outcome <> "completed" then printfn $" !! {outcome}" | |
| noteFlight after | |
| dump h after | |
| after | |
| /// A scenario that runs but whose trace is not worth printing in full. | |
| let quiet label h inbox = | |
| let h, trace, _ = Hospital.run 4000 h inbox | |
| lastTrace <- trace | |
| allTrace <- allTrace @ trace | |
| noteFlight h | |
| ignore label | |
| h | |
| /// The one scenario that needs the cascades interleaved rather than run one after the | |
| /// other: Rule 36's race (see `Hospital.runWith`). | |
| let racing label h inbox = | |
| printfn "" | |
| printfn $"== {label} ==" | |
| plansBefore h | |
| let after, trace, outcome = Hospital.runWith false 4000 h inbox | |
| lastTrace <- trace | |
| allTrace <- allTrace @ trace | |
| trace |> List.filter (Envelope.noise >> not) |> List.iter (Envelope.show >> printfn "%s") | |
| if outcome <> "completed" then printfn $" !! {outcome}" | |
| noteFlight after | |
| dump h after | |
| after | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // UC-1 User launches GenPRES | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let uc1 () = | |
| printfn "" | |
| printfn "############### UC-1 User launches GenPRES ###############" | |
| // Goal: User A gets GenPRES open on the Patient they have selected, able to | |
| // prescribe, save and sign. Patient 1 has no PatientRecord, so the Session starts | |
| // from nothing. | |
| let launched = step "UC-1 main — A launches for Patient 1" world (launchAs ucA.Login (Some pat1)) | |
| expect "UC-1 one Session, open, and the Role is the registry's" | |
| (openCount launched = 1 | |
| && stateOf 1 launched = Some OpenOrGone | |
| && (newestRecord launched |> Option.bind _.User |> Option.map _.Role) = Some Prescriber) | |
| expect "UC-1 the SessionRecord carries the UserContext and the mail address (Concept 9)" | |
| ((newestRecord launched |> Option.bind _.User) = Some ucA | |
| && (newestRecord launched |> Option.bind _.Mail) = Some mailA) | |
| expect "UC-1 step 8: the launch asserted a login and a Patient, and no Role" | |
| (saw (function | |
| | LaunchResolved(_, _, a) -> a.Login = ucA.Login && a.Patient = Some pat1 | |
| | _ -> false)) | |
| expect "UC-1 step 9: the Role came from the UserRegistry (Rule 5)" | |
| (saw (function UserResolved(_, uc, _) -> uc.Role = Prescriber | _ -> false)) | |
| expect "UC-1 step 10: a PIN is set, so the launch continues and none is asked for (Rule 23)" | |
| (saw (function CredentialRead(_, Some c) -> c.Pin.IsSome | _ -> false) | |
| && never (function PinRequired _ -> true | _ -> false)) | |
| expect "UC-1 step 11: the PatientContext was read once, at the launch (Concept 2)" | |
| (saw (function PatientDataRead _ -> true | _ -> false) | |
| && countOf (function ReadPatientData _ -> true | _ -> false) = 1) | |
| expect "UC-1 step 12: Patient 1 has no record, so the Session starts from nothing (Rule 19)" | |
| (openedAt 1 launched = None && workingAt 1 launched = []) | |
| expect "UC-1 step 13: the SessionRecord was written to the Database (Concept 9)" | |
| (launched.Database.Sessions.Length = 1) | |
| // Rule 33. The Client is handed the token it will return with every create, and | |
| // it could not have made one: the mac is over a secret it never sees. | |
| expect "UC-1 step 14: the Client holds an opened-with token that verifies (Rule 33)" | |
| ((clientOf 1 launched |> Option.bind _.Opened |> Option.map Token.verifyOpened) = Some true) | |
| expect "UC-1 and from here the Server keeps nothing of the Session (Rule 31)" | |
| (launched.GenPres.InFlight.IsEmpty && launched.GenPres.Pending.IsEmpty) | |
| expect "UC-1 the credential is spent, and is not kept by GenPRES (Rule 2)" | |
| (launched.Broker.Launches |> Map.forall (fun _ l -> l.Redeemed)) | |
| // ── UC-1 ext 1a — no Patient is active in the MainEHR Session ── | |
| // GenPRES opens and A can prescribe, but a TreatmentPlan cannot be opened or created. | |
| let noPatient = step "UC-1 ext 1a — no Patient active" world (launchAs ucA.Login None) | |
| expect "1a a Session opens without a Patient" | |
| (openCount noPatient = 1 && (newestRecord noPatient |> Option.bind _.Patient) = None) | |
| expect "1a steps 11 and 12 are skipped: no data to fetch, no PatientRecord to read" | |
| (never (function ReadPatientData _ -> true | _ -> false) | |
| && never (function ReadRecord _ -> true | _ -> false)) | |
| let _ = | |
| step "UC-1 ext 1a — and a TreatmentPlan cannot be created (Rule 12)" noPatient | |
| [ act 1 (Prescribes(OrderContextId "oc-9")); act 1 Saves ] | |
| expect "1a prescribing works; creating does not" | |
| (saw (function Computed _ -> true | _ -> false) | |
| && saw (function NoTreatmentPlanHere -> true | _ -> false) | |
| && never (function TreatmentPlanCreated _ -> true | _ -> false)) | |
| // ── UC-1 ext 3a — the Broker is unreachable ── | |
| // The one launch failure the EHR side can report: its Broker edge is | |
| // request-response and it has not yet exited. | |
| let brokerDown = | |
| step "UC-1 ext 3a — the Broker is unreachable" world | |
| (envt Broker (Stop Broker) :: launchAs ucA.Login (Some pat1)) | |
| expect "3a no credential exists, nothing was opened, and the LaunchScript says so" | |
| (saw (function LaunchError _ -> true | _ -> false) | |
| && never (function LaunchPrepared _ -> true | _ -> false) | |
| && brokerDown.Clients.IsEmpty | |
| && openCount brokerDown = 0) | |
| // ── UC-1 ext 5a / 7a — the Server is unreachable ── | |
| // In production the Client is served by the Server, so ext 5a shows the browser's | |
| // own error page and nothing of ours. The model has no notion of the page being | |
| // served, so both extensions arrive here as the same answer: unavailable, and the | |
| // credential stays in the address bar (Consequence 4) so a refresh retries for as | |
| // long as Rule 3 allows. | |
| let serverDown = | |
| step "UC-1 ext 5a — the Server is down at the launch" world | |
| (envt GenPresServer (Stop GenPresServer) :: launchAs ucA.Login (Some pat1)) | |
| expect "5a nothing opens, and the Client is told GenPRES is unavailable" | |
| (openCount serverDown = 0 | |
| && sawTo (GenPresClient(BrowserId 1)) (function ServerUnreachable -> true | _ -> false)) | |
| let retried = | |
| step "UC-1 ext 7a — the Server comes back, and F5 retries within Rule 3's window" serverDown | |
| (ticks 2 @ [ envt GenPresServer (Start GenPresServer); atClient 1 Refresh ]) | |
| expect "7a the parked credential is still good, and the Session opens" | |
| (openCount retried = 1 && saw (function SessionOpened _ -> true | _ -> false)) | |
| let expired = | |
| step "UC-1 ext 7a — but not past credentialTtl (Rule 3, Rule 28)" serverDown | |
| (ticks 10 @ [ envt GenPresServer (Start GenPresServer); atClient 1 Refresh ]) | |
| expect "7a an aged credential opens nothing" | |
| (openCount expired = 0 | |
| && saw (function LaunchRejected(_, _, CredentialExpired) -> true | _ -> false) | |
| && saw (function LaunchRefused -> true | _ -> false)) | |
| // ── UC-1 ext 7b — the LaunchCredential is stolen before the Client presents it ── | |
| // The credential is in a URL (Consequence 4): whoever presents it first wins | |
| // (Rule 2), so within its lifetime a thief gains A's Session. Park it unredeemed — | |
| // a Server that was down when the browser opened — so the thief can actually get | |
| // there first. Left to itself the legitimate Client redeems inside the launch | |
| // cascade and no thief could ever win, which is the race below, not this one. | |
| let parked = | |
| step "UC-1 ext 7b — the credential sits unredeemed in the address bar" world | |
| (envt GenPresServer (Stop GenPresServer) :: launchAs ucA.Login (Some pat1)) | |
| let thief = | |
| step "UC-1 ext 7b — a thief presents it first, and gains A's Session" parked | |
| [ | |
| envt GenPresServer (Start GenPresServer) | |
| { | |
| From = GenPresClient(BrowserId 99) | |
| To = GenPresServer | |
| Msg = RedeemLaunch(LaunchCredential "cred-0001") | |
| } | |
| ] | |
| expect "7b the thief holds a live Session — and it is bound to A (Rules 2, 5)" | |
| (openCount thief = 1 | |
| && (newestRecord thief |> Option.bind _.User |> Option.map _.UserId) = Some ucA.UserId | |
| && sidAt 99 thief = (newestRecord thief |> Option.map _.Id)) | |
| let aLocked = step "UC-1 ext 7b — and A's own retry is refused" thief [ atClient 1 Refresh ] | |
| expect "7b whoever presents it first wins; the loser gets nothing (Rule 2)" | |
| (openCount aLocked = 1 | |
| && saw (function LaunchRejected(_, _, AlreadyRedeemed) -> true | _ -> false) | |
| && saw (function LaunchRefused -> true | _ -> false)) | |
| // The damage is bounded: the thief saves at most Unsigned work in A's name. | |
| let thiefSaved = | |
| step "UC-1 ext 7b — the thief can save, and it is attributed to A" aLocked | |
| [ | |
| act 99 (Prescribes(OrderContextId "oc-stolen")) | |
| act 99 Saves | |
| ] | |
| expect "7b Unsigned work in A's name — attribution is per credential, not per person (Rules 14, 32)" | |
| (planCount pat1 thiefSaved = 1 | |
| && (headOf pat1 thiefSaved |> Option.map _.By) = Some ucA | |
| && (headOf pat1 thiefSaved |> Option.map _.Signed) = Some false) | |
| let thiefBlocked = | |
| step "UC-1 ext 7b — but cannot sign: signing needs A's PIN" thiefSaved | |
| [ act 99 (Signs(Pin "guess")) ] | |
| expect "7b nothing is Signed, and the guess costs A an attempt (Concept 14; Rules 22, 27)" | |
| (saw (function PinRejected _ -> true | _ -> false) | |
| && (recordFor pat1 thiefBlocked |> PatientRecord.latestSigned).IsNone | |
| && (credentialOf ucA thiefBlocked |> Option.map _.AttemptCount) = Some 1) | |
| let evicted = | |
| step "UC-1 ext 7b — A's own next launch evicts them" thiefBlocked (launchAs ucA.Login (Some pat1)) | |
| expect "7b one open Session, the thief's superseded, and A is told something held it (Rules 7, 10)" | |
| (openCount evicted = 1 | |
| && (match stateOf 1 evicted with Some(Ended(Superseded, _)) -> true | _ -> false) | |
| && saw (function PriorSessionNotice _ -> true | _ -> false)) | |
| // Single use and short lifetime are the containment, not prevention (Rules 2, 3, 28). | |
| // The other ordering is legal too, and is what happens when nothing delays the | |
| // legitimate Client: it redeems inside the launch cascade, and the thief arrives | |
| // to a credential already spent. | |
| let lostRace = | |
| step "UC-1 ext 7b — a thief arriving second gets nothing" world | |
| (launchAs ucA.Login (Some pat1) | |
| @ [ | |
| { | |
| From = GenPresClient(BrowserId 99) | |
| To = GenPresServer | |
| Msg = RedeemLaunch(LaunchCredential "cred-0001") | |
| } | |
| ]) | |
| expect "7b arriving second, the thief is refused and opens nothing (Rule 2)" | |
| (openCount lostRace = 1 | |
| && saw (function LaunchRejected(_, _, AlreadyRedeemed) -> true | _ -> false) | |
| && sidAt 99 lostRace = None) | |
| // ── UC-1 ext 8a — the credential is expired or already redeemed ── | |
| // Covered by 7a and 7b above: both end in LaunchRefused, which carries no reason. | |
| // ext 8b — the Server cannot reach the Broker. | |
| // Starting from ext 5a: the credential is still parked and unredeemed, so there is | |
| // something for the Server to fail to redeem. | |
| let _ = | |
| step "UC-1 ext 8b — the Broker is unreachable at redemption" serverDown | |
| [ | |
| envt GenPresServer (Start GenPresServer) | |
| envt Broker (Stop Broker) | |
| atClient 1 Refresh | |
| ] | |
| expect "8b redemption fails and the launch is refused" | |
| (saw (function LaunchRejected(_, _, BrokerUnreachable) -> true | _ -> false) | |
| && saw (function LaunchRefused -> true | _ -> false)) | |
| // ── UC-1 ext 9a — the UserRegistry cannot say what the login may do ── | |
| let registryDown = | |
| step "UC-1 ext 9a — the registry is unreachable" world | |
| (envt UserRegistry (Stop UserRegistry) :: launchAs ucA.Login (Some pat1)) | |
| expect "9a no launched Session, and rights fail closed (Rules 5, 6)" | |
| (openCount registryDown = 0 | |
| && saw (function AuthorityUnavailable -> true | _ -> false)) | |
| expect "9a the anonymous open is offered — relaunching would not cure this" | |
| ((clientOf 1 registryDown |> Option.map _.AnonymousOffer) = Some true) | |
| let wentAnonymous = | |
| step "UC-1 ext 9a — A accepts, and gets a fresh anonymous open (Rule 6)" registryDown | |
| [ atClient 1 AcceptAnonymousOffer ] | |
| expect "9a it carries nothing over from the launch: no User, no Patient" | |
| (openCount wentAnonymous = 1 | |
| && (newestRecord wentAnonymous |> Option.bind _.User) = None | |
| && (newestRecord wentAnonymous |> Option.bind _.Patient) = None) | |
| // ── UC-1 ext 9b — the launching User is a Reader ── | |
| let asReader = step "UC-1 ext 9b — C, a Reader, launches for Patient 3" world (launchAs ucC.Login (Some pat3)) | |
| expect "9b a Session opens, with the Reader Role" | |
| ((newestRecord asReader |> Option.bind _.User |> Option.map _.Role) = Some Reader) | |
| expect "9b a Reader is never asked for a PIN — not asked and ignored, but not asked (Rule 25)" | |
| (never (function ReadCredential _ -> true | _ -> false) | |
| && never (function PinRequired _ -> true | _ -> false)) | |
| expect "9b and starts from the most recent Signed TreatmentPlan, not A's Unsigned head (Rules 18, 19)" | |
| (openedAt 1 asReader = Some p3Signed.Id) | |
| // ── UC-1 ext 10a — User A has no PIN yet ── | |
| // First launch as a Prescriber. UC-2 is this case in full: a PIN must be set | |
| // before the launch continues (Rule 24). | |
| // ── UC-1 ext 11a — the PatientDataPlatform is unreachable ── | |
| let noPlatform = | |
| step "UC-1 ext 11a — the PatientDataPlatform is unreachable" world | |
| (envt PatientDataPlatform (Stop PatientDataPlatform) :: launchAs ucA.Login (Some pat2)) | |
| expect "11a the launch continues: a PatientId and no data (Concept 2)" | |
| (openCount noPlatform = 1 | |
| && (newestRecord noPlatform |> Option.bind _.Patient) = Some pat2 | |
| && dataAt 1 noPlatform = None) | |
| expect "11a TreatmentPlans work as normal — the PatientId is there (Rule 12)" | |
| (openedAt 1 noPlatform = Some p2Signed.Id) | |
| // ── UC-1 ext 13a / 14a — A already has an open Session, or the wrong Patient ── | |
| // Rule 7 is per User, not per Patient, so both are the same mechanism: the | |
| // earlier Session is closed and A is told work in it may have been lost. | |
| let wrongPatient = step "UC-1 ext 14a — A launched for the wrong Patient" world (launchAs ucA.Login (Some pat1)) | |
| let relaunched = | |
| step "UC-1 ext 13a/14a — A activates Patient 2 and relaunches" wrongPatient | |
| (launchAs ucA.Login (Some pat2)) | |
| expect "14a the wrong Session is closed, whichever Patient it was for (Rule 7)" | |
| (openCount relaunched = 1 | |
| && (newestRecord relaunched |> Option.bind _.Patient) = Some pat2 | |
| && (match stateOf 1 relaunched with Some(Ended(Superseded, _)) -> true | _ -> false)) | |
| expect "13a and A is told, once (Rule 10)" | |
| (saw (function PriorSessionNotice _ -> true | _ -> false) | |
| && wasTold 1 relaunched) | |
| launched | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // UC-2 First launch as a Prescriber: no PIN yet | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let uc2 () = | |
| printfn "" | |
| printfn "############### UC-2 First launch as a Prescriber ###############" | |
| // Precondition: UC-1 has reached step 10 — the UserContext carries the Prescriber | |
| // Role, and no PIN is set for that login. | |
| let noPin = | |
| { world with Database.Credentials = world.Database.Credentials |> Map.remove ucA.Login } | |
| let asked = | |
| step "UC-2 main — A launches as a Prescriber for the first time" noPin | |
| (launchAs ucA.Login (Some pat1)) | |
| expect "UC-2 the launch stops and asks for a PIN, and offers nothing else (Rules 23, 24)" | |
| (saw (function PinRequired _ -> true | _ -> false) | |
| && openCount asked = 0 | |
| && never (function SessionOpened _ -> true | _ -> false)) | |
| // The order matters: a login the registry does not recognise never gets to enrol. | |
| expect "UC-2 the PIN is offered only after the registry recognised the login (Rule 24)" | |
| (before (function UserResolved _ -> true | _ -> false) | |
| (function PinRequired _ -> true | _ -> false)) | |
| let unknown = | |
| step "UC-2 — a login the registry does not know never reaches the PIN question" noPin | |
| (launchAs (LoginName "dr.x") (Some pat1)) | |
| expect "UC-2 an unrecognised login is refused before any PIN is offered (UC-1 ext 9a)" | |
| (openCount unknown = 0 | |
| && saw (function NotAuthorised -> true | _ -> false) | |
| && never (function PinRequired _ -> true | _ -> false)) | |
| let enrolled = | |
| step "UC-2 steps 3 to 5 — A chooses a PIN and the launch continues" asked | |
| [ atClient 1 (ChoosePin(Pin "9999")) ] | |
| expect "UC-2 step 4: the PIN is set on A's UserCredential, created since GenPRES held none" | |
| ((credentialOf ucA enrolled |> Option.bind _.Pin) = Some(Pin "9999")) | |
| expect "UC-2 step 4: the change is recorded and A is mailed, the first setting included (Rule 26)" | |
| ((mailsTo mailA enrolled).Length = 1 | |
| && enrolled.Audit |> List.exists (fun a -> a.Contains "PIN set")) | |
| expect "UC-2 a newly set PIN starts with a count of zero (Rule 27)" | |
| ((credentialOf ucA enrolled |> Option.map _.AttemptCount) = Some 0) | |
| expect "UC-2 step 5: the launch continues from UC-1 step 11" | |
| (openCount enrolled = 1 | |
| && saw (function SessionOpened _ -> true | _ -> false) | |
| && saw (function PatientDataRead _ -> true | _ -> false)) | |
| // ── UC-2 ext 3a — A does not set a PIN ── | |
| let askedAgain = | |
| step "UC-2 ext 3a — A does not set a PIN; the next launch asks again" asked | |
| (launchAs ucA.Login (Some pat1)) | |
| expect "3a a required PIN is not set, so no Session is opened (Rule 6) — and it asks again" | |
| (openCount askedAgain = 0 | |
| && saw (function PinRequired _ -> true | _ -> false)) | |
| // A Reader in the same position is never asked at all. | |
| let readerNoPin = | |
| step "UC-2 — a Reader with no PIN is never asked (Rule 25)" noPin | |
| (launchAs ucC.Login (Some pat2)) | |
| expect "UC-2 the Reader's launch is never held up by a PIN" | |
| (openCount readerNoPin = 1 | |
| && never (function PinRequired _ -> true | _ -> false)) | |
| // Not a document scenario — model hygiene. A launch that stalls mid-flight would | |
| // otherwise sit in the launch table forever, which is harmless here and a leak in | |
| // production. Everything but AwaitingPinChoice is waiting on a round trip and is | |
| // collectable; that one waits on a human and is not. | |
| let stalled = | |
| let ctx = | |
| { | |
| Client = GenPresClient(BrowserId 1) | |
| Launch = LaunchNo 1 | |
| Assertion = { Login = ucA.Login; Patient = Some pat1 } | |
| } | |
| { world with | |
| GenPres.Pending = | |
| Map.empty | |
| |> Map.add (AttemptId 90) { Stage = AwaitingUser ctx; Since = 0 } | |
| |> Map.add (AttemptId 91) { Stage = AwaitingPinChoice(ctx, ucA, mailA); Since = 0 } } | |
| let swept = step "UC-2 — an abandoned launch is collected; one waiting on a human is not" stalled (ticks 12) | |
| expect "UC-2 a launch stalled mid-flight is dropped; one suspended on a human is kept (UC-2 step 3)" | |
| (not (swept.GenPres.Pending.ContainsKey(AttemptId 90)) | |
| && swept.GenPres.Pending.ContainsKey(AttemptId 91)) | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // UC-3 Prescribe, save and sign | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let uc3 () = | |
| printfn "" | |
| printfn "############### UC-3 Prescribe, save and sign ###############" | |
| // Precondition: UC-1 completed — A has an open Session for Patient 2, started from | |
| // its Signed head, and holds the Prescriber Role. | |
| let opened = quiet "UC-3 precondition" world (launchAs ucA.Login (Some pat2)) | |
| expect "UC-3 precondition: the Session started from Patient 2's Signed head (Rule 19)" | |
| (openedAt 1 opened = Some p2Signed.Id) | |
| let saved = | |
| step "UC-3 steps 1 and 2 — A prescribes and saves" opened | |
| [ | |
| act 1 (Prescribes(OrderContextId "oc-4")) | |
| act 1 Saves | |
| ] | |
| expect "UC-3 step 1: each change goes to the Server, which answers from the payload (Rules 8, 31)" | |
| (saw (function Computed _ -> true | _ -> false)) | |
| expect "UC-3 step 2: nothing blocks and nothing warns (Rules 20, 21)" | |
| (never (function CreateBlocked _ -> true | _ -> false) | |
| && never (function UnsignedWorkNotice _ -> true | _ -> false)) | |
| expect "UC-3 step 2: an Unsigned TreatmentPlan is appended, carrying A's UserContext (Rule 14)" | |
| (planCount pat2 saved = 2 | |
| && (headOf pat2 saved |> Option.map _.Signed) = Some false | |
| && (headOf pat2 saved |> Option.map _.By) = Some ucA) | |
| expect "UC-3 step 2: and its base (Concept 13)" | |
| ((headOf pat2 saved |> Option.bind _.Base) = Some p2Signed.Id) | |
| expect "UC-3 Rule 14: the OrderContext changed in the Session is stamped" | |
| (headOf pat2 saved | |
| |> Option.map _.Orders | |
| |> Option.defaultValue [] | |
| |> List.forall (fun o -> o.Stamp = Some ucA)) | |
| expect "UC-3 Rule 33: the create carried the opened-with token, and a new one came back" | |
| (saw (function SessionRequest(_, CreateTreatmentPlan(_, _, _, t, _)) -> t.Plan = Some p2Signed.Id | _ -> false) | |
| && openedAt 1 saved = (headOf pat2 saved |> Option.map _.Id)) | |
| let signed = step "UC-3 step 3 — A signs" saved [ act 1 (Signs pinA) ] | |
| expect "UC-3 step 3: a Signed TreatmentPlan in A's name (Concept 14, Rules 14, 15)" | |
| (planCount pat2 signed = 3 | |
| && (headOf pat2 signed |> Option.map _.Signed) = Some true | |
| && (headOf pat2 signed |> Option.map _.By) = Some ucA) | |
| expect "UC-3 step 3: it is now the most recent Signed TreatmentPlan and counts clinically (Rule 16)" | |
| ((recordFor pat2 signed |> PatientRecord.latestSigned |> Option.map _.Id) | |
| = (headOf pat2 signed |> Option.map _.Id)) | |
| expect "UC-3 the correct entry reset the wrong-entry count (Rule 27)" | |
| ((credentialOf ucA signed |> Option.map _.AttemptCount) = Some 0) | |
| // ── UC-3 ext 2a — the record has moved on since A opened ── | |
| // If what appeared is Unsigned, A is notified and may create anyway or hold off | |
| // (Rule 21). If a Signed TreatmentPlan appeared, creating is blocked (Rule 20). UC-6 is | |
| // this case in full. | |
| // ── UC-3 ext 3a — A does not sign ── | |
| // The Unsigned TreatmentPlan stays at the head, inert. Only A can open it (Rule 18), | |
| // and it counts for nothing until signed (Rule 16). | |
| expect "3a an Unsigned head does not count clinically (Rule 16)" | |
| ((recordFor pat2 saved |> PatientRecord.latestSigned |> Option.map _.Id) = Some p2Signed.Id) | |
| expect "3a only its creator can open it (Rule 18)" | |
| ((recordFor pat2 saved |> PatientRecord.mayOpen ucA.UserId (headOf pat2 saved).Value.Id).IsSome | |
| && (recordFor pat2 saved |> PatientRecord.mayOpen ucB.UserId (headOf pat2 saved).Value.Id).IsNone) | |
| // ── UC-3 ext 3b — A gives the wrong PIN ── | |
| let wrongOnce = step "UC-3 ext 3b — A gives the wrong PIN" saved [ act 1 (Signs(Pin "0000")) ] | |
| expect "3b verification fails and no TreatmentPlan is created" | |
| (planCount pat2 wrongOnce = 2 | |
| && saw (function PinRejected _ -> true | _ -> false)) | |
| expect "3b the count is on the UserCredential, not the Session (Rule 27)" | |
| ((credentialOf ucA wrongOnce |> Option.map _.AttemptCount) = Some 1) | |
| let atLimit = | |
| step "UC-3 ext 3b — and at the limit the Session ends (Rules 9, 27)" wrongOnce | |
| [ | |
| act 1 (Signs(Pin "0000")) | |
| act 1 (Signs(Pin "0000")) | |
| ] | |
| expect "3b the Session ends at the wrong-PIN limit" | |
| (openCount atLimit = 0 | |
| && (match stateOf 1 atLimit with Some(Ended(WrongPinLimit, _)) -> true | _ -> false) | |
| && saw (function SessionEnded(Some WrongPinLimit) -> true | _ -> false)) | |
| expect "3b the count survives the Session: it is not a fresh start" | |
| ((credentialOf ucA atLimit |> Option.map _.AttemptCount) = Some wrongPinLimit) | |
| // ── UC-3 ext 3c — A signs without saving first ── | |
| // Steps 2 and 3 become one act, and the block and notification checks run before | |
| // the PIN is asked for. Set up a block, and watch nothing ask for a credential. | |
| let bSigned = | |
| quiet "UC-3 ext 3c setup — B signs while A is open" opened | |
| (launchAs ucB.Login (Some pat2) | |
| @ [ act 2 (Prescribes(OrderContextId "oc-5")); act 2 (Signs pinB) ]) | |
| let blocked = | |
| step "UC-3 ext 3c — A signs without saving, and is blocked before the PIN" bSigned | |
| [ act 1 (Prescribes(OrderContextId "oc-6")); act 1 (Signs pinA) ] | |
| expect "3c the block is decided first: no credential is ever read (Rules 20, 22)" | |
| (saw (function CreateBlocked _ -> true | _ -> false) | |
| && never (function ReadCredential(ForRequest _, _) -> true | _ -> false)) | |
| expect "3c and nothing was appended" | |
| (planCount pat2 blocked = planCount pat2 bSigned) | |
| signed | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // UC-4 Work left unsigned by someone else | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let uc4 () = | |
| printfn "" | |
| printfn "############### UC-4 Work left unsigned by someone else ###############" | |
| // Precondition: Patient 3's head is an Unsigned TreatmentPlan of A's over an older | |
| // Signed one. B launches, holding the Prescriber Role. | |
| let bOpen = step "UC-4 step 1 — B launches for Patient 3" world (launchAs ucB.Login (Some pat3)) | |
| expect "UC-4 step 1: B starts from the older Signed TreatmentPlan, not A's Unsigned head (Rules 18, 19)" | |
| (openedAt 1 bOpen = Some p3Signed.Id) | |
| expect "UC-4 step 1: A's Unsigned work is closed to B (Rule 18)" | |
| (recordFor pat3 bOpen |> PatientRecord.mayOpen ucB.UserId p3Unsigned.Id).IsNone | |
| let warned = | |
| step "UC-4 step 2 — B enters orders and saves" bOpen | |
| [ | |
| act 1 (Prescribes(OrderContextId "oc-7")) | |
| act 1 Saves | |
| ] | |
| expect "UC-4 step 2: B is told an Unsigned TreatmentPlan of another User is newer (Rule 21)" | |
| (saw (function UnsignedWorkNotice(uc, _) -> uc = ucA | _ -> false)) | |
| expect "UC-4 step 2: and the create waits — the User may still choose not to" | |
| (planCount pat3 warned = 2) | |
| expect "UC-4 step 2: the notice came with a token naming what it disclosed (Rule 34)" | |
| ((noticeAt 1 warned |> Option.map _.Disclosed) = Some [ p3Unsigned.Id ]) | |
| // Rule 34: proceeding is re-sending the create with that token. There is no | |
| // `Proceed` message; holding off is simply not sending this. | |
| let bSaved = step "UC-4 step 2 — B chooses to create anyway, returning the token" warned [ act 1 Saves ] | |
| expect "UC-4 step 2: an Unsigned TreatmentPlan of B's own is appended (Rules 14, 34)" | |
| (planCount pat3 bSaved = 3 | |
| && (headOf pat3 bSaved |> Option.map _.By) = Some ucB | |
| && (headOf pat3 bSaved |> Option.map _.Signed) = Some false) | |
| expect "UC-4 step 2: and the notice is spent — the token does not linger (Rule 34)" | |
| (noticeAt 1 bSaved = None) | |
| let bSigned = step "UC-4 step 3 — B signs" bSaved [ act 1 (Signs pinB) ] | |
| expect "UC-4 step 3: a Signed TreatmentPlan in B's name; it now counts clinically (Rules 15, 16)" | |
| ((headOf pat3 bSigned |> Option.map _.Signed) = Some true | |
| && (recordFor pat3 bSigned |> PatientRecord.latestSigned |> Option.map _.By) = Some ucB) | |
| // ── step 4 — A's Unsigned work is superseded ── | |
| let aReturns = step "UC-4 step 4 — A launches for Patient 3 after B signed" bSigned (launchAs ucA.Login (Some pat3)) | |
| expect "UC-4 step 4: A's Session starts from B's Signed TreatmentPlan (Rule 19)" | |
| (openedAt 2 aReturns | |
| = (recordFor pat3 aReturns |> PatientRecord.latestSigned |> Option.map _.Id)) | |
| // "Nobody but User A could ever open it, and now not even User A can act on it." | |
| // Rule 18 does still let A open their own Unsigned TreatmentPlan — it is unqualified. | |
| // What has gone is the acting: Rule 20 blocks creating anything from it, because | |
| // B's Signed TreatmentPlan is newer than the one A would then have opened with. | |
| let aOnDeadEnd = | |
| step "UC-4 step 4 — A opens the old work, and can do nothing with it" aReturns | |
| [ | |
| act 2 (OpensTreatmentPlan p3Unsigned.Id) | |
| act 2 (Signs pinA) | |
| ] | |
| expect "UC-4 step 4: A may still open their own Unsigned TreatmentPlan (Rule 18)" | |
| (saw (function TreatmentPlanOpened(id, _, _) -> id = p3Unsigned.Id | _ -> false)) | |
| expect "UC-4 step 4: but creating anything from it is blocked, for good (Rule 20)" | |
| (saw (function CreateBlocked _ -> true | _ -> false) | |
| && (headOf pat3 aOnDeadEnd |> Option.map _.By) = Some ucB) | |
| // ── UC-4 ext 2a — B holds off at the notification ── | |
| // There is nothing to send: under Rule 34 the create is only made by returning the | |
| // token, so holding off is the absence of a message. `warned` is that state. | |
| expect "2a nothing is created; both TreatmentPlans stand, each usable only by its own User" | |
| (planCount pat3 warned = 2 | |
| && (headOf pat3 warned |> Option.map _.Id) = Some p3Unsigned.Id) | |
| // ── UC-4 ext 4a — A launches before B signs ── | |
| let aBeforeBSigns = | |
| step "UC-4 ext 4a — A launches for Patient 3 before B signs" bSaved (launchAs ucA.Login (Some pat3)) | |
| expect "4a A starts from A's own Unsigned head: B's is Unsigned too, so it does not supersede (Rule 19)" | |
| (openedAt 2 aBeforeBSigns = Some p3Unsigned.Id) | |
| let aSignsFirst = | |
| step "UC-4 ext 4a — A may sign: no newer Signed TreatmentPlan exists (Rule 20)" aBeforeBSigns | |
| [ act 2 (Signs pinA) ] | |
| expect "4a A is notified of B's newer Unsigned work (Rule 21), and nothing is created yet" | |
| (saw (function UnsignedWorkNotice(uc, _) -> uc = ucB | _ -> false)) | |
| let aWon = step "UC-4 ext 4a — A re-sends with the token, and signing first blocks B" aSignsFirst [ act 2 (Signs pinA) ] | |
| expect "4a whichever of the two signs first blocks the other (Rule 20)" | |
| ((recordFor pat3 aWon |> PatientRecord.latestSigned |> Option.map _.By) = Some ucA) | |
| let bNowBlocked = step "UC-4 ext 4a — B tries to sign after A did" aWon [ act 1 (Signs pinB) ] | |
| expect "4a B is blocked by A's Signed TreatmentPlan (Rule 20)" | |
| (saw (function CreateBlocked _ -> true | _ -> false)) | |
| ignore bNowBlocked | |
| bSigned | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // UC-5 Someone else takes over the workstation | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let uc5 () = | |
| printfn "" | |
| printfn "############### UC-5 Someone else takes over the workstation ###############" | |
| // Precondition: A has an open Session for Patient 1 and walks away. Possibility 1: | |
| // this is not ours to prevent, only to handle. | |
| let aWalksAway = quiet "UC-5 precondition" world (launchAs ucA.Login (Some pat1)) | |
| let bSaves = | |
| step "UC-5 steps 1 to 4 — B works and saves in A's Session" aWalksAway | |
| [ | |
| act 1 (Prescribes(OrderContextId "oc-8")) | |
| act 1 Saves | |
| ] | |
| expect "UC-5 step 4: the TreatmentPlan is created under the Session's credentials — A's (Rules 14, 32)" | |
| (planCount pat1 bSaves = 1 | |
| && (headOf pat1 bSaves |> Option.map _.By) = Some ucA) | |
| expect "UC-5 step 4: and so are the stamps on every OrderContext B changed (Rules 14, 35)" | |
| (headOf pat1 bSaves | |
| |> Option.map _.Orders | |
| |> Option.defaultValue [] | |
| |> List.forall (fun o -> o.Stamp = Some ucA)) | |
| // Step 5: signing always names the Session's User, so the Client asks for A's PIN. | |
| // Step 6: B does not have it. Supplying their own proves nothing — the Server | |
| // verifies against the Session's User's credential (Rules 14, 22, 32). | |
| let bTriesToSign = step "UC-5 steps 5 and 6 — B signs, with the only PIN they have" bSaves [ act 1 (Signs pinB) ] | |
| expect "UC-5 step 6: the work stays Unsigned and does not count clinically (Rules 15, 16)" | |
| (saw (function PinRejected _ -> true | _ -> false) | |
| && (headOf pat1 bTriesToSign |> Option.map _.Signed) = Some false | |
| && (recordFor pat1 bTriesToSign |> PatientRecord.latestSigned).IsNone) | |
| // Signing always names the Session's User, so verification runs against A's | |
| // credential whoever is at the keyboard — which is exactly what caps B's guessing | |
| // in ext 6a, and why it costs A their allowance rather than B's. | |
| expect "UC-5 the wrong entry counted against the Session's User's credential — A's, not B's (Rules 22, 27, 32)" | |
| ((credentialOf ucB bTriesToSign |> Option.map _.AttemptCount) = Some 0 | |
| && (credentialOf ucA bTriesToSign |> Option.map _.AttemptCount) = Some 1) | |
| // ── UC-5 ext 5a — B relaunches as themselves ── | |
| let bOwnSession = | |
| step "UC-5 ext 5a — B relaunches from MainEHR as themselves, Patient 1 active" bSaves | |
| (launchAs ucB.Login (Some pat1)) | |
| expect "5a Rule 7 is per User: a Session of B's own opens, and A's is untouched" | |
| (openCount bOwnSession = 2 | |
| && (openOfUser ucA bOwnSession).Length = 1 | |
| && (openOfUser ucB bOwnSession).Length = 1) | |
| expect "5a it starts from nothing: no Signed TreatmentPlan, and the Unsigned one is A's (Rules 18, 19)" | |
| (openedAt 2 bOwnSession = None) | |
| let bReEnters = | |
| step "UC-5 ext 5a — B re-enters the work and signs; the notice comes first" bOwnSession | |
| [ | |
| act 2 (Prescribes(OrderContextId "oc-8")) | |
| act 2 (Signs pinB) | |
| ] | |
| expect "5a B is notified of the newer Unsigned TreatmentPlan (Rule 21)" | |
| (saw (function UnsignedWorkNotice(uc, _) -> uc = ucA | _ -> false)) | |
| let bSignedOwn = step "UC-5 ext 5a — B re-sends with the token (Rule 34)" bReEnters [ act 2 (Signs pinB) ] | |
| expect "5a and signs as themselves (Rules 14, 15)" | |
| ((headOf pat1 bSignedOwn |> Option.map _.By) = Some ucB | |
| && (headOf pat1 bSignedOwn |> Option.map _.Signed) = Some true) | |
| // ── UC-5 ext 5b — B cannot log in to MainEHR at that workstation ── | |
| // No path to a Session of B's own. The work stays Unsigned until A opens it in a | |
| // Session of their own and signs; nobody else can. | |
| expect "5b the work stays Unsigned, and only A can ever act on it (Rules 18, 19)" | |
| ((recordFor pat1 bSaves |> PatientRecord.mayOpen ucB.UserId (headOf pat1 bSaves).Value.Id).IsNone | |
| && (recordFor pat1 bSaves |> PatientRecord.mayOpen ucA.UserId (headOf pat1 bSaves).Value.Id).IsSome) | |
| // ── UC-5 ext 6a — B guesses instead ── | |
| let guessed = | |
| step "UC-5 ext 6a — B guesses at A's PIN" bTriesToSign | |
| [ | |
| act 1 (Signs(Pin "0001")) | |
| act 1 (Signs(Pin "0002")) | |
| ] | |
| expect "6a at the configured number of consecutive wrong entries the Session ends (Rules 9, 27)" | |
| (openCount guessed = 0 | |
| && (match stateOf 1 guessed with Some(Ended(WrongPinLimit, _)) -> true | _ -> false)) | |
| expect "6a the Unsigned TreatmentPlan stays, and A is told of the ending (Rule 10)" | |
| (planCount pat1 guessed = 1 | |
| && saw (function SessionEnded(Some WrongPinLimit) -> true | _ -> false)) | |
| let relaunchNoHelp = | |
| step "UC-5 ext 6a — relaunching as A does not reset the count (Rule 27)" guessed | |
| (launchAs ucA.Login (Some pat1) @ [ act 2 (Signs(Pin "0003")) ]) | |
| expect "6a the count belongs to the UserCredential, so guessing is capped outright" | |
| ((credentialOf ucA relaunchNoHelp |> Option.map _.AttemptCount |> Option.map (fun c -> c >= wrongPinLimit)) | |
| = Some true) | |
| bSignedOwn | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // UC-6 Two Users, one Patient | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let uc6 () = | |
| printfn "" | |
| printfn "############### UC-6 Two Users, one Patient ###############" | |
| // Precondition: A and B each hold an open Session for Patient 2. Rule 7 permits | |
| // this: it limits Sessions per User, not per Patient. | |
| let both = | |
| step "UC-6 precondition — A and B each open a Session for Patient 2" world | |
| (launchAs ucA.Login (Some pat2) @ launchAs ucB.Login (Some pat2)) | |
| expect "UC-6 Rule 7 limits Sessions per User, not per Patient: both are open" | |
| (openCount both = 2 | |
| && (openOfUser ucA both).Length = 1 | |
| && (openOfUser ucB both).Length = 1) | |
| // Guarantee 3, and the reason it now holds by construction: the two carts are in | |
| // two Browsers, and the Server holds neither (Rule 31). | |
| expect "UC-6 the two carts are in the two Clients, and nowhere else (Rule 31, Guarantee 3)" | |
| (both.GenPres.InFlight.IsEmpty && sidAt 1 both <> sidAt 2 both) | |
| let aSigned = | |
| step "UC-6 step 2 — A saves and signs" both | |
| [ | |
| act 1 (Prescribes(OrderContextId "oc-a")) | |
| act 1 Saves | |
| act 1 (Signs pinA) | |
| ] | |
| expect "UC-6 step 2: an Unsigned then a Signed TreatmentPlan in A's name" | |
| (planCount pat2 aSigned = 3 | |
| && (headOf pat2 aSigned |> Option.map _.Signed) = Some true | |
| && (headOf pat2 aSigned |> Option.map _.By) = Some ucA) | |
| // Consequence 6: neither User saw the other's work — a Client only learns anything | |
| // at its own next request. | |
| let bBlocked = | |
| step "UC-6 step 3 — B saves, and is blocked" aSigned | |
| [ | |
| act 2 (Prescribes(OrderContextId "oc-b")) | |
| act 2 Saves | |
| ] | |
| expect "UC-6 step 3: a Signed TreatmentPlan newer than the one B opened with blocks the create (Rule 20)" | |
| (saw (function CreateBlocked _ -> true | _ -> false) | |
| && planCount pat2 bBlocked = 3) | |
| let bTookOver = | |
| step "UC-6 step 4 — B opens A's Signed TreatmentPlan, which lifts the block (Rule 17)" bBlocked | |
| [ act 2 (OpensTreatmentPlan (headOf pat2 bBlocked).Value.Id) ] | |
| expect "UC-6 step 4: opening it re-mints the token, so it is what the Session opened with (Rule 33)" | |
| (saw (function TreatmentPlanOpened _ -> true | _ -> false) | |
| && openedAt 2 bTookOver = (headOf pat2 bBlocked |> Option.map _.Id)) | |
| let bReapplied = | |
| step "UC-6 step 4 — B reapplies their own work, saves and signs" bTookOver | |
| [ | |
| act 2 (Prescribes(OrderContextId "oc-b")) | |
| act 2 (Signs pinB) | |
| ] | |
| expect "UC-6 step 4: the signature attests the whole set in B's name (Rules 14, 15)" | |
| ((headOf pat2 bReapplied |> Option.map _.By) = Some ucB | |
| && (headOf pat2 bReapplied |> Option.map _.Signed) = Some true) | |
| // Rule 14, the half that only shows here — and Rule 35, which is how the Server | |
| // knows: it diffed the payload against the base TreatmentPlan, rather than believing | |
| // any stamp the Client sent. | |
| let orders = headOf pat2 bReapplied |> Option.map _.Orders |> Option.defaultValue [] | |
| expect "UC-6 step 4: the OrderContext B changed carries B's stamp" | |
| (orders |> List.exists (fun o -> o.Id = OrderContextId "oc-b" && o.Stamp = Some ucB)) | |
| expect "UC-6 step 4: the ones B left untouched keep A's stamp (Rules 14, 35)" | |
| (orders |> List.exists (fun o -> o.Id = OrderContextId "oc-a" && o.Stamp = Some ucA) | |
| && orders |> List.exists (fun o -> o.Id = OrderContextId "oc-1" && o.Stamp = Some ucA)) | |
| // ── UC-6 ext 2a — B saves, Unsigned, before A signs ── | |
| let bSavedFirst = | |
| step "UC-6 ext 2a — B saves Unsigned before A signs" both | |
| [ | |
| act 2 (Prescribes(OrderContextId "oc-b")) | |
| act 2 Saves | |
| ] | |
| expect "2a B is not blocked: nothing Signed is newer (Rule 20)" | |
| (never (function CreateBlocked _ -> true | _ -> false) | |
| && planCount pat2 bSavedFirst = 2) | |
| let _ = | |
| step "UC-6 ext 2a — but A is notified when creating (Rule 21)" bSavedFirst | |
| [ act 1 Saves ] | |
| expect "2a A is told whose work it is, and may proceed or hold off" | |
| (saw (function UnsignedWorkNotice(uc, _) -> uc = ucB | _ -> false)) | |
| // Nothing attested is ever lost: the PatientRecord is append-only (Concept 12), so | |
| // a Signed TreatmentPlan survives whatever follows. What is not protected is Unsigned | |
| // work: superseded, it can never be signed (Rules 19, 20). | |
| expect "UC-6 nothing attested is lost: A's Signed TreatmentPlan survives B's (Concept 12)" | |
| (recordFor pat2 bReapplied | |
| |> _.Plans | |
| |> List.exists (fun s -> s.Signed && s.By = ucA)) | |
| bReapplied | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // UC-7 A User forgets their PIN | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let uc7 () = | |
| printfn "" | |
| printfn "############### UC-7 A User forgets their PIN ###############" | |
| // Precondition: A has an open Session and a UserCredential with a PIN set but | |
| // forgotten. | |
| let opened = quiet "UC-7 precondition" world (launchAs ucA.Login (Some pat2)) | |
| let reset = step "UC-7 steps 1 and 2 — A asks GenPRES to reset the PIN" opened [ act 1 AsksPinReset ] | |
| expect "UC-7 step 2: the PIN is removed and the UserCredential stays (Concept 7)" | |
| ((credentialOf ucA reset).IsSome | |
| && (credentialOf ucA reset |> Option.bind _.Pin) = None) | |
| // Rule 26 has to reach A with no Session in memory to ask, so the address comes | |
| // off the SessionRecord (Concept 9). | |
| expect "UC-7 step 2: the change is recorded and A is mailed (Rule 26, Concept 9)" | |
| ((mailsTo mailA reset).Length = 1 | |
| && reset.Audit |> List.exists (fun a -> a.Contains "PIN removed")) | |
| let stillSaves = | |
| step "UC-7 step 3 — A can still save" reset | |
| [ act 1 (Prescribes(OrderContextId "oc-r")); act 1 Saves ] | |
| expect "UC-7 step 3: saving still works" | |
| (planCount pat2 stillSaves = 2 && (headOf pat2 stillSaves |> Option.map _.Signed) = Some false) | |
| let cannotSign = | |
| step "UC-7 step 3 — but a UserCredential without a PIN cannot sign" stillSaves [ act 1 (Signs pinA) ] | |
| expect "UC-7 step 3: nothing verifies, so nothing is signed (Concept 7, Rule 22)" | |
| (saw (function PinRejected _ -> true | _ -> false) | |
| && (recordFor pat2 cannotSign |> PatientRecord.latestSigned |> Option.map _.Id) = Some p2Signed.Id) | |
| let relaunched = step "UC-7 step 4 — A relaunches; UC-2 follows (Rules 23, 24)" reset (launchAs ucA.Login (Some pat2)) | |
| expect "UC-7 step 4: no PIN is set and A holds the Prescriber Role, so one is asked for" | |
| (saw (function PinRequired _ -> true | _ -> false)) | |
| let newPin = step "UC-7 step 5 — A sets a new PIN, and is mailed again (Rule 26)" relaunched [ atClient 2 (ChoosePin(Pin "5555")) ] | |
| expect "UC-7 step 5: a mail goes to A each time — once at removal, once at the new setting" | |
| ((mailsTo mailA newPin).Length = 2) | |
| expect "UC-7 step 5: and the new PIN starts with a count of zero (Rule 27)" | |
| ((credentialOf ucA newPin |> Option.map _.AttemptCount) = Some 0) | |
| // ── UC-7 ext 1a — B, at A's open workstation, triggers the reset ── | |
| // Every step runs the same way, and B ends up able to sign as A. This cannot be | |
| // prevented: a launch proves control of a MainEHR Session, not a person | |
| // (Possibility 1). What the mails buy is that A learns of it through a channel B | |
| // does not control. | |
| let bResets = step "UC-7 ext 1a — B triggers the reset at A's open workstation" opened [ act 1 AsksPinReset ] | |
| expect "1a it cannot be prevented — but the mail goes to A, not to whoever did it" | |
| ((credentialOf ucA bResets |> Option.bind _.Pin) = None | |
| && (mailsTo mailA bResets).Length = 1 | |
| && (mailsTo mailB bResets).Length = 0) | |
| expect "1a and the recorded change shows which TreatmentPlans were signed afterwards" | |
| (bResets.Audit |> List.exists (fun a -> a.Contains "dr.a")) | |
| newPin | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // UC-8 User opens GenPRES directly | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let uc8 () = | |
| printfn "" | |
| printfn "############### UC-8 User opens GenPRES directly ###############" | |
| // GenPRES as Clinical Decision Support, not as order management. No launch, so no | |
| // LaunchCredential — and GenPRES cannot know who is at the keyboard. | |
| let anon = step "UC-8 steps 1 to 3 — A opens the GenPRES address in a browser" world [ atClient 1 OpenDirectly ] | |
| expect "UC-8 step 3: an anonymous Session — no User, no Role, no PatientId (Rule 13)" | |
| (openCount anon = 1 | |
| && (newestRecord anon |> Option.bind _.User) = None | |
| && (newestRecord anon |> Option.bind _.Patient) = None) | |
| expect "UC-8 step 3: its SessionRecord binds to no User (Concept 9)" | |
| ((recNo 1 anon |> Option.bind _.User) = None | |
| && (recNo 1 anon |> Option.bind _.Launch) = None) | |
| expect "UC-8 anonymous use needs no Role and no UserRegistry check" | |
| (never (function ResolveUser _ -> true | _ -> false)) | |
| let prescribing = | |
| step "UC-8 step 4 — A prescribes: Patient Data and OrderContexts by hand" anon | |
| [ | |
| act 1 (EntersPatientData(PatientData "3y, 14kg, by hand")) | |
| act 1 (Prescribes(OrderContextId "oc-x")) | |
| ] | |
| expect "UC-8 step 4: prescribing works, Patient Data included (Concepts 2, 15)" | |
| ((dataAt 1 prescribing).IsSome && (workingAt 1 prescribing).Length = 1) | |
| expect "UC-8 step 4: each request refreshes the Session's idle clock (Rules 8, 12)" | |
| (countOf (function SessionRequest _ -> true | _ -> false) = 2 | |
| && lastSeenOf 1 prescribing > (recNo 1 anon |> Option.map _.LastSeen)) | |
| let noSaving = step "UC-8 step 5 — nothing can be saved" prescribing [ act 1 Saves; act 1 (Signs pinA) ] | |
| expect "UC-8 step 5: no TreatmentPlan can be opened or created (Rule 12)" | |
| (saw (function NoTreatmentPlanHere -> true | _ -> false) | |
| && never (function TreatmentPlanCreated _ -> true | _ -> false)) | |
| expect "UC-8 neither the PatientRecord nor the PatientDataPlatform is ever touched" | |
| (never (function ReadRecord _ -> true | _ -> false) | |
| && never (function ReadPatientData _ -> true | _ -> false)) | |
| expect "UC-8 step 5: the work exists only in the Client (Rule 31)" | |
| ((workingAt 1 noSaving).Length = 1 && noSaving.GenPres.InFlight.IsEmpty) | |
| let idled = step "UC-8 — and it need not idle out: keeping it has no consequence (Rule 13)" noSaving (ticks 30) | |
| expect "UC-8 an anonymous Session may remain open indefinitely (Rule 13)" | |
| (openCount idled = 1 && stateOf 1 idled = Some OpenOrGone) | |
| // ── UC-8 ext 2a — the browser does present a LaunchCredential ── | |
| // That is a launch: UC-1 from step 7. Covered by UC-1 throughout. | |
| // ── UC-8 ext 2b — the same Browser later launches properly ── | |
| // The launched Session is another Session; Rule 7 counts only a User's Sessions, | |
| // and an anonymous Session binds to none. (The model opens the launch in a fresh | |
| // tab, since a launch always does; nothing in the Rules turns on that.) | |
| let alsoLaunched = step "UC-8 ext 2b — the same person later launches properly" idled (launchAs ucA.Login (Some pat1)) | |
| expect "2b the anonymous Session is untouched and may simply live on (Rules 7, 13)" | |
| (openCount alsoLaunched = 2 | |
| && stateOf 1 alsoLaunched = Some OpenOrGone | |
| && never (function PriorSessionNotice _ -> true | _ -> false)) | |
| // And the work in the browser is gone when the browser goes — it was only ever | |
| // there (Rule 31). | |
| let browserClosed = step "UC-8 step 5 — and it is gone when the browser goes" idled [ atClient 1 CloseBrowser ] | |
| expect "UC-8 step 5: the cart dies with the browser (Rule 31)" | |
| (workingAt 1 browserClosed).IsEmpty | |
| idled | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // UC-9 A Session ends out from under the User | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let uc9 () = | |
| printfn "" | |
| printfn "############### UC-9 A Session ends out from under the User ###############" | |
| // Precondition: UC-3 ran through step 2 and stopped. A's Session for Patient 2 is | |
| // open, an Unsigned TreatmentPlan of A's stands at the head, and further unsaved | |
| // changes sit on the screen. | |
| let saved = | |
| quiet "UC-9 precondition" world | |
| (launchAs ucA.Login (Some pat2) | |
| @ [ | |
| act 1 (Prescribes(OrderContextId "oc-4")) | |
| act 1 Saves | |
| act 1 (Prescribes(OrderContextId "oc-unsaved")) | |
| ]) | |
| expect "UC-9 precondition: an Unsigned TreatmentPlan stands, and one change is unsaved" | |
| (planCount pat2 saved = 2 && (workingAt 1 saved).Length = 3) | |
| let idled = step "UC-9 step 1 — A is called away, and the idle clock runs out (Rules 8, 9)" saved (ticks 12) | |
| expect "UC-9 step 1: the Session ends and its record is marked ended" | |
| (openCount idled = 0 | |
| && (match stateOf 1 idled with Some(Ended(Idle, _)) -> true | _ -> false)) | |
| expect "UC-9 step 1: the ending creates the obligation — a notice is now owed (Rule 10)" | |
| (noticeOf 1 idled = Some Owed) | |
| // Step 2: the Server cannot reach the Client, which keeps showing a live-looking | |
| // screen (Consequence 6). Nothing was sent, and nothing could have been. | |
| expect "UC-9 step 2: nothing was sent to the Client when the Session ended (Consequence 6)" | |
| (never (function SessionEnded _ -> true | _ -> false)) | |
| let told = step "UC-9 steps 3 and 4 — A returns and acts" idled [ act 1 (Prescribes(OrderContextId "oc-later")) ] | |
| expect "UC-9 step 4: the request is refused, A is told, and the obligation is spent" | |
| (saw (function SessionEnded(Some Idle) -> true | _ -> false) | |
| && wasTold 1 told) | |
| // Step 5, the change the stateless design makes. The unsaved work was never | |
| // anywhere but the Client (Rule 31): the ended Session accepts nothing, but the | |
| // Client still holds it. | |
| expect "UC-9 step 5: the unsaved changes are still in the Client (Rule 31)" | |
| ((workingAt 1 told) |> List.exists (fun o -> o.Id = OrderContextId "oc-unsaved")) | |
| expect "UC-9 step 5: and they never reached the record (Concept 15)" | |
| (planCount pat2 told = 2 | |
| && (headOf pat2 told | |
| |> Option.map _.Orders | |
| |> Option.defaultValue [] | |
| |> List.exists (fun o -> o.Id = OrderContextId "oc-unsaved") | |
| |> not)) | |
| expect "UC-9 step 5: the Unsigned TreatmentPlan stands, A's own to resume (Rules 18, 19)" | |
| ((recordFor pat2 told |> PatientRecord.startsFrom ucA.UserId |> Option.map _.Id) | |
| = (headOf pat2 told |> Option.map _.Id)) | |
| let relaunched = step "UC-9 step 6 — A relaunches. Told once already, A is not told again (Rule 10)" told (launchAs ucA.Login (Some pat2)) | |
| expect "UC-9 step 6: no notice at the relaunch" | |
| (never (function PriorSessionNotice _ -> true | _ -> false)) | |
| // Step 5 continued: the Client may offer to carry the surviving cart into the next | |
| // Session as fresh prescribing (Concept 15) — not as a resumed Session. | |
| let carried = | |
| step "UC-9 step 5 — A carries the surviving work into the new Session" relaunched | |
| [ | |
| act 2 (CarriesOverFrom(BrowserId 1)) | |
| act 2 Saves | |
| ] | |
| expect "UC-9 step 5: the unsaved OrderContext from before the idle-out lands in the next TreatmentPlan" | |
| (headOf pat2 carried | |
| |> Option.map _.Orders | |
| |> Option.defaultValue [] | |
| |> List.exists (fun o -> o.Id = OrderContextId "oc-unsaved")) | |
| expect "UC-9 step 5: and it is fresh prescribing — stamped by A in this Session (Rules 14, 35)" | |
| (headOf pat2 carried | |
| |> Option.map _.Orders | |
| |> Option.defaultValue [] | |
| |> List.exists (fun o -> o.Id = OrderContextId "oc-unsaved" && o.Stamp = Some ucA)) | |
| // "They survive exactly as far as the browser does — closed, they are gone." | |
| let browserGoneFirst = | |
| step "UC-9 step 5 — but close the browser first, and there is nothing to carry" told | |
| ([ atClient 1 CloseBrowser ] @ launchAs ucA.Login (Some pat2)) | |
| let nothingCarried = | |
| step "UC-9 step 5 — the new Session gets only what the record held" browserGoneFirst | |
| [ | |
| act 2 (CarriesOverFrom(BrowserId 1)) | |
| act 2 Saves | |
| ] | |
| expect "UC-9 step 5: closed is gone — the unsaved work is nowhere" | |
| (headOf pat2 nothingCarried | |
| |> Option.map _.Orders | |
| |> Option.defaultValue [] | |
| |> List.exists (fun o -> o.Id = OrderContextId "oc-unsaved") | |
| |> not) | |
| // ── UC-9 ext 1a — the Server restarts instead ── | |
| // Nothing ends. This is the headline change of the stateless design: the Session's | |
| // identity and standing are in its SessionRecord, its work is in the Client, and | |
| // the Server held neither (Rules 9, 31). | |
| let restarted = | |
| step "UC-9 ext 1a — the Server restarts instead" saved | |
| [ envt GenPresServer (Stop GenPresServer); tick; envt GenPresServer (Start GenPresServer) ] | |
| expect "1a nothing ends: the Session is still open (Rules 9, 31)" | |
| (openCount restarted = 1 && stateOf 1 restarted = Some OpenOrGone) | |
| expect "1a the Server settled nothing at the start — there was nothing to settle" | |
| (never (function ReadSessionRecords ForSweep -> true | _ -> false) | |
| && never (function WriteSessionRecord _ -> true | _ -> false)) | |
| expect "1a the Client still holds its cart (Rule 31)" | |
| ((workingAt 1 restarted).Length = 3) | |
| let seenBefore = lastSeenOf 1 restarted | |
| let afterRestart = | |
| step "1a — and the next request continues the Session (Rules 8, 9)" restarted | |
| [ act 1 (Prescribes(OrderContextId "oc-after-restart")) ] | |
| expect "1a the next request is served, and refreshes the idle clock" | |
| (saw (function Computed _ -> true | _ -> false) | |
| && never (function SessionEnded _ -> true | _ -> false) | |
| && lastSeenOf 1 afterRestart > seenBefore) | |
| // While it is down, requests fail as in UC-1 ext 7a. | |
| let whileDown = | |
| step "1a — while it is down, requests fail as in UC-1 ext 7a" saved | |
| [ envt GenPresServer (Stop GenPresServer); act 1 (Prescribes(OrderContextId "oc-nope")) ] | |
| expect "1a a down Server is unreachable, not an ending" | |
| (saw (function ServerUnreachable -> true | _ -> false) | |
| && stateOf 1 whileDown = Some OpenOrGone) | |
| // ── UC-9 ext 1b — A opens another Session at another workstation ── | |
| let elsewhere = step "UC-9 ext 1b — A opens another Session instead" saved (launchAs ucA.Login (Some pat2)) | |
| expect "1b the launch itself ends the old Session, and the notice comes with it (Rules 7, 9, 10)" | |
| (openCount elsewhere = 1 | |
| && (match stateOf 1 elsewhere with Some(Ended(Superseded, _)) -> true | _ -> false) | |
| && saw (function PriorSessionNotice _ -> true | _ -> false)) | |
| let oldTab = step "UC-9 ext 1b — the old Client's next request is refused, but not told twice (Rule 10)" elsewhere [ act 1 (Prescribes(OrderContextId "oc-z")) ] | |
| expect "1b refused, and the notice is not repeated" | |
| (saw (function SessionRefused -> true | _ -> false) | |
| && never (function SessionEnded _ -> true | _ -> false)) | |
| ignore oldTab | |
| told | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // UC-10 A Reader consults a Patient | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let uc10 () = | |
| printfn "" | |
| printfn "############### UC-10 A Reader consults a Patient ###############" | |
| // Precondition: as UC-9. Patient 2's head is an Unsigned TreatmentPlan of A's over its | |
| // Signed one. C, a Reader, launches for Patient 2. | |
| let withUnsignedHead = | |
| quiet "UC-10 precondition" world | |
| (launchAs ucA.Login (Some pat2) | |
| @ [ act 1 (Prescribes(OrderContextId "oc-4")); act 1 Saves ]) | |
| let reading = step "UC-10 steps 1 and 2 — C launches for Patient 2" withUnsignedHead (launchAs ucC.Login (Some pat2)) | |
| expect "UC-10 step 1: C never creates a TreatmentPlan, so no Unsigned one of their own can exist (Rules 17, 19)" | |
| (openedAt 2 reading = Some p2Signed.Id) | |
| expect "UC-10 step 2: C reads the plan that counts clinically (Rule 16)" | |
| (workingAt 2 reading = p2Signed.Orders) | |
| expect "UC-10 step 3: A's newer Unsigned TreatmentPlan is not shown — only its creator can open it (Rule 18)" | |
| (recordFor pat2 reading |> PatientRecord.mayOpen ucC.UserId (headOf pat2 reading).Value.Id).IsNone | |
| // Its existence is not announced either: the only notification of another's | |
| // Unsigned work fires at TreatmentPlan creation (Rule 21), and a Reader never creates. | |
| let exploring = | |
| step "UC-10 step 4 — C prescribes within the Session to explore alternatives" reading | |
| [ | |
| act 2 (Prescribes(OrderContextId "oc-what-if")) | |
| act 2 Saves | |
| act 2 (Signs(Pin "0000")) | |
| ] | |
| expect "UC-10 step 4: prescribing works (Concept 15), but saving and signing are not offered" | |
| (saw (function Computed _ -> true | _ -> false) | |
| && saw (function NotPermitted -> true | _ -> false) | |
| && planCount pat2 exploring = planCount pat2 reading) | |
| expect "UC-10 step 4: no PIN is ever asked for, and none is ever read (Rule 25)" | |
| (never (function PinRequired _ -> true | _ -> false) | |
| && never (function ReadCredential _ -> true | _ -> false)) | |
| expect "UC-10 step 3: the existence of A's Unsigned work goes unannounced (Rule 21)" | |
| (never (function UnsignedWorkNotice _ -> true | _ -> false)) | |
| // A Reader can thus be reading a plan that a Prescriber already knows is being | |
| // superseded. The model accepts this deliberately: Unsigned work counts for | |
| // nothing until it is signed (Rule 16), so there is nothing yet to tell a Reader. | |
| exploring | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // UC-11 A User resumes their own Unsigned work | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let uc11 () = | |
| printfn "" | |
| printfn "############### UC-11 A User resumes their own Unsigned work ###############" | |
| // Precondition: UC-9 completed. Patient 2's head is A's own Unsigned TreatmentPlan over | |
| // the older Signed one, and A launches again for Patient 2. | |
| let parked = | |
| quiet "UC-11 precondition" world | |
| (launchAs ucA.Login (Some pat2) | |
| @ [ act 1 (Prescribes(OrderContextId "oc-4")); act 1 Saves ] | |
| @ ticks 12) | |
| let resumed = step "UC-11 step 1 — A launches again for Patient 2" parked (launchAs ucA.Login (Some pat2)) | |
| expect "UC-11 step 1: the Session starts from A's own Unsigned head, not the older Signed one (Rule 19)" | |
| (openedAt 2 resumed = (headOf pat2 resumed |> Option.map _.Id)) | |
| expect "UC-11 step 1: and it carries the work that was saved into it" | |
| ((workingAt 2 resumed).Length = 2) | |
| let signed = | |
| step "UC-11 steps 2 and 3 — A reviews, adjusts and signs" resumed | |
| [ | |
| act 2 (Prescribes(OrderContextId "oc-4")) | |
| act 2 (Signs pinA) | |
| ] | |
| expect "UC-11 step 3: nothing blocks and nothing warns (Rules 20, 21)" | |
| (never (function CreateBlocked _ -> true | _ -> false) | |
| && never (function UnsignedWorkNotice _ -> true | _ -> false)) | |
| expect "UC-11 step 3: a Signed TreatmentPlan in A's name; it now counts clinically (Rules 14, 15, 16)" | |
| ((headOf pat2 signed |> Option.map _.Signed) = Some true | |
| && (headOf pat2 signed |> Option.map _.By) = Some ucA | |
| && (recordFor pat2 signed |> PatientRecord.latestSigned |> Option.map _.Id) | |
| = (headOf pat2 signed |> Option.map _.Id)) | |
| expect "UC-11 step 3: the TreatmentPlan's base is the Unsigned one it was resumed from (Concept 13)" | |
| ((headOf pat2 signed |> Option.bind _.Base) = (headOf pat2 resumed |> Option.map _.Id)) | |
| // ── UC-11 ext 3a — a Signed TreatmentPlan appeared since the launch ── | |
| let bSignedMeanwhile = | |
| quiet "UC-11 ext 3a setup" resumed | |
| (launchAs ucB.Login (Some pat2) | |
| @ [ | |
| act 3 (Prescribes(OrderContextId "oc-c")) | |
| // B opened from the older Signed TreatmentPlan, so A's Unsigned head is | |
| // newer and Rule 21 fires on B as well. B re-sends with the token. | |
| act 3 (Signs pinB) | |
| act 3 (Signs pinB) | |
| ]) | |
| let aBlocked = | |
| step "UC-11 ext 3a — A signs after a Signed TreatmentPlan appeared" bSignedMeanwhile | |
| [ act 2 (Signs pinA) ] | |
| expect "3a creating is blocked (Rule 20)" | |
| (saw (function CreateBlocked _ -> true | _ -> false)) | |
| let aRecovered = | |
| step "UC-11 ext 3a — A opens it, reapplies, and continues (Rule 17; UC-6 step 4)" aBlocked | |
| [ | |
| act 2 (OpensTreatmentPlan (headOf pat2 aBlocked).Value.Id) | |
| act 2 (Prescribes(OrderContextId "oc-4")) | |
| act 2 (Signs pinA) | |
| ] | |
| expect "3a opening the newest Signed TreatmentPlan lifts the block" | |
| ((headOf pat2 aRecovered |> Option.map _.By) = Some ucA | |
| && (headOf pat2 aRecovered |> Option.map _.Signed) = Some true) | |
| // ── UC-11 ext 3b — another User's Unsigned TreatmentPlan appeared since the launch ── | |
| let bSavedMeanwhile = | |
| quiet "UC-11 ext 3b setup" resumed | |
| (launchAs ucB.Login (Some pat2) | |
| @ [ | |
| act 3 (Prescribes(OrderContextId "oc-d")) | |
| act 3 Saves | |
| act 3 Saves | |
| ]) | |
| let aWarned = | |
| step "UC-11 ext 3b — A signs after another's Unsigned TreatmentPlan appeared" bSavedMeanwhile | |
| [ act 2 (Signs pinA) ] | |
| expect "3b A is notified and decides (Rule 21) — not blocked (Rule 20)" | |
| (saw (function UnsignedWorkNotice(uc, _) -> uc = ucB | _ -> false) | |
| && never (function CreateBlocked _ -> true | _ -> false)) | |
| ignore aWarned | |
| signed | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // UC-12 User closes GenPRES | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let uc12 () = | |
| printfn "" | |
| printfn "############### UC-12 User closes GenPRES ###############" | |
| // Precondition: UC-11 completed. A has an open Session for Patient 2, its work | |
| // signed, and nothing unsaved remains. | |
| let signedUp = | |
| quiet "UC-12 precondition" world | |
| (launchAs ucA.Login (Some pat2) | |
| @ [ act 1 (Prescribes(OrderContextId "oc-4")); act 1 (Signs pinA) ]) | |
| let closed = step "UC-12 steps 2 and 3 — A closes the Session in the Client" signedUp [ act 1 ClosesSession ] | |
| expect "UC-12 step 3: the Session ends, marked closed by the User (Rule 9, Concept 9)" | |
| (openCount closed = 0 | |
| && (match stateOf 1 closed with Some(Ended(ClosedByUser, _)) -> true | _ -> false)) | |
| expect "UC-12 step 3: and no notice is ever owed — not owed and then skipped (Rule 10)" | |
| (noticeOf 1 closed = Some NotOwed) | |
| let nextLaunch = step "UC-12 step 4 — the next launch starts clean" closed (launchAs ucA.Login (Some pat2)) | |
| expect "UC-12 step 4: no notice follows — Rule 10 speaks only of endings other than by the User" | |
| (never (function PriorSessionNotice _ -> true | _ -> false) | |
| && noticeOf 1 nextLaunch = Some NotOwed) | |
| // ── UC-12 ext 1a — unsaved changes remain at the close ── | |
| let withUnsaved = | |
| quiet "UC-12 ext 1a setup" signedUp [ act 1 (Prescribes(OrderContextId "oc-dangling")) ] | |
| let closedAnyway = step "UC-12 ext 1a — A closes with unsaved changes: closed is closed" withUnsaved [ act 1 ClosesSession ] | |
| expect "1a they existed only in the Client and are gone (Rule 31); anything saved stands (Concept 12)" | |
| (openCount closedAnyway = 0 | |
| && (workingAt 1 closedAnyway).IsEmpty | |
| && planCount pat2 closedAnyway = 2 | |
| && (headOf pat2 closedAnyway | |
| |> Option.map _.Orders | |
| |> Option.defaultValue [] | |
| |> List.exists (fun o -> o.Id = OrderContextId "oc-dangling") | |
| |> not)) | |
| // ── UC-12 ext 2a — A closes the browser instead ── | |
| let browserGone = step "UC-12 ext 2a — A closes the browser instead" signedUp [ atClient 1 CloseBrowser ] | |
| expect "2a nothing reaches the Server, so no close can be inferred (Rule 9)" | |
| (openCount browserGone = 1 | |
| && stateOf 1 browserGone = Some OpenOrGone | |
| && never (function SessionRequest _ -> true | _ -> false)) | |
| let idledOut = step "UC-12 ext 2a — the Session idles out instead" browserGone (ticks 12) | |
| expect "2a it idles out, and A is told at the next opportunity (Rule 10; UC-9)" | |
| (match stateOf 1 idledOut with Some(Ended(Idle, _)) -> true | _ -> false) | |
| let harmlessNotice = step "UC-12 ext 2a — a harmless notice, the price of the indistinguishability" idledOut (launchAs ucA.Login (Some pat2)) | |
| expect "2a the notice arrives at the next launch" | |
| (saw (function PriorSessionNotice _ -> true | _ -> false)) | |
| ignore harmlessNotice | |
| closed | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // UC-13 A User's authority is withdrawn | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let uc13 () = | |
| printfn "" | |
| printfn "############### UC-13 A User's authority is withdrawn ###############" | |
| // Precondition: UC-3 ran once more through step 2 and stopped. Patient 2's head is | |
| // an Unsigned TreatmentPlan of A's over the Signed one. Then the UserRegistry stops | |
| // returning a Role for A's login. | |
| let aSaved = | |
| quiet "UC-13 precondition" world | |
| (launchAs ucA.Login (Some pat2) | |
| @ [ act 1 (Prescribes(OrderContextId "oc-4")); act 1 Saves ]) | |
| let withdrawn = { aSaved with Registry.Users = aSaved.Registry.Users |> Map.remove ucA.Login } | |
| let refused = step "UC-13 steps 1 and 2 — A launches; the registry returns no Role" withdrawn (launchAs ucA.Login (Some pat2)) | |
| // A's Session from the precondition is still open, and stays open — that is ext 1a | |
| // below. What the failed launch must not do is open another one. | |
| expect "UC-13 step 2: no Role, so the launch opens no Session (Rules 5, 6)" | |
| (saw (function UserUnresolved(_, NoRole) -> true | _ -> false) | |
| && saw (function NotAuthorised -> true | _ -> false) | |
| && never (function SessionOpened _ -> true | _ -> false)) | |
| let cds = step "UC-13 step 3 — A accepts the anonymous open: CDS is all that remains" refused [ atClient 2 AcceptAnonymousOffer ] | |
| expect "UC-13 step 3: hand-entered patients, no records, nothing saved (UC-8; Rule 13)" | |
| ((newestRecord cds |> Option.bind _.User) = None | |
| && (newestRecord cds |> Option.bind _.Patient) = None) | |
| let againRefused = step "UC-13 step 3 — every later launch ends the same way (Rule 5)" cds (launchAs ucA.Login (Some pat2)) | |
| expect "UC-13 step 3: the Role is taken from the registry at each launch, so the withdrawal stands" | |
| (saw (function NotAuthorised -> true | _ -> false)) | |
| expect "UC-13 step 4: A's UserCredential remains, but is inert (Concepts 7, 14)" | |
| ((credentialOf ucA againRefused).IsSome | |
| && (credentialOf ucA againRefused |> Option.bind _.Pin).IsSome) | |
| // ── step 5 — the Unsigned TreatmentPlan is stranded ── | |
| let bWorksPast = | |
| step "UC-13 step 5 — B's next Session starts from the Signed TreatmentPlan below (Rule 19)" againRefused | |
| (launchAs ucB.Login (Some pat2)) | |
| expect "UC-13 step 5: only A could open the stranded work, and A can no longer reach it" | |
| (openedAt 4 bWorksPast = Some p2Signed.Id) | |
| let bNotified = | |
| step "UC-13 step 5 — B is notified of the stranded work at the save (Rule 21)" bWorksPast | |
| [ act 4 (Prescribes(OrderContextId "oc-e")); act 4 (Signs pinB) ] | |
| expect "UC-13 step 5: B is told whose work it is" | |
| (saw (function UnsignedWorkNotice(uc, _) -> uc = ucA | _ -> false)) | |
| let superseded = step "UC-13 step 5 — B re-sends with the token, and their signature supersedes it for good" bNotified [ act 4 (Signs pinB) ] | |
| expect "UC-13 step 5: B's Signed TreatmentPlan now counts, and A's work can never be signed (Rules 16, 20)" | |
| ((headOf pat2 superseded |> Option.map _.By) = Some ucB | |
| && (headOf pat2 superseded |> Option.map _.Signed) = Some true) | |
| // ── UC-13 ext 1a — the withdrawal happens while A's Session is open ── | |
| // Rule 5 checks at the launch, just as Concept 2 reads data at the launch: | |
| // in-Session currency of the Role is accepted the same way. With no Session in | |
| // memory, this is Concept 9 doing the work — the Role comes off the SessionRecord | |
| // the launch wrote, and the registry is not asked again (Rule 32). | |
| let stillWorking = | |
| step "UC-13 ext 1a — the withdrawal lands while A's Session is open" withdrawn | |
| [ act 1 (Prescribes(OrderContextId "oc-f")); act 1 (Signs pinA) ] | |
| expect "1a the open Session keeps the Role its launch established (Concept 9, Rule 32)" | |
| ((headOf pat2 stillWorking |> Option.map _.By) = Some ucA | |
| && (headOf pat2 stillWorking |> Option.map _.Signed) = Some true | |
| && never (function ResolveUser _ -> true | _ -> false)) | |
| superseded | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // Rules 32 to 36 — the stateless design under attack | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // | |
| // The cart is in the Client now (Rule 31), so everything the Server used to know by | |
| // remembering it must instead arrive with the request — and be worth nothing unless | |
| // the Server itself vouched for it. These are the tests that say so: a Client that | |
| // edits a token, invents one, lies about the Patient, or forges a stamp, and a | |
| // Database that arbitrates two Servers racing for the same head. | |
| /// A Client putting an envelope on the wire by hand — which is what an attacker has, | |
| /// and what the honest Client's own branches deliberately never do. | |
| let fromClient b msg = { From = GenPresClient(BrowserId b); To = GenPresServer; Msg = msg } | |
| let tokensAndArbitration () = | |
| printfn "" | |
| printfn "############### Rules 32-36 The stateless design under attack ###############" | |
| // ── Rule 33: an opened-with token the Client edited ── | |
| let both = | |
| quiet "tokens precondition" world | |
| (launchAs ucA.Login (Some pat2) @ launchAs ucB.Login (Some pat2)) | |
| let bWon = | |
| step "Rule 33 setup — B signs, so A's opened-with token is now stale" both | |
| [ act 2 (Prescribes(OrderContextId "oc-b")); act 2 (Signs pinB) ] | |
| let newestSigned = (recordFor pat2 bWon |> PatientRecord.latestSigned |> Option.map _.Id).Value | |
| let honestStale = step "Rule 33 — A's honest but stale token: blocked, as before (Rule 20)" bWon [ act 1 Saves ] | |
| expect "Rule 33 an honest stale token is believed, and Rule 20 does the refusing" | |
| (saw (function CreateBlocked _ -> true | _ -> false) | |
| && never (function CreateRefused _ -> true | _ -> false) | |
| && planCount pat2 honestStale = planCount pat2 bWon) | |
| // Now A edits the token to name the newest Signed TreatmentPlan — which would lift the | |
| // Rule 20 block — and guesses at the mac. | |
| let forged = | |
| let sid = (sidAt 1 bWon).Value | |
| let tok = { Sid = sid; Patient = Some pat2; Plan = Some newestSigned; Mac = "mac|guessed" } | |
| step "Rule 33 — A edits the token to name the newest Signed TreatmentPlan" bWon | |
| [ fromClient 1 (SessionRequest(sid, CreateTreatmentPlan(None, workingAt 1 bWon, None, tok, None))) ] | |
| expect "Rule 33 the token does not verify, so the create is refused — not merely blocked" | |
| (saw (function CreateRefused _ -> true | _ -> false) | |
| && never (function TreatmentPlanCreated _ -> true | _ -> false) | |
| && planCount pat2 forged = planCount pat2 bWon) | |
| // ── Rule 34: a notice token that is out of date, and one the Client made ── | |
| let bOpen = quiet "Rule 34 precondition" world (launchAs ucB.Login (Some pat3)) | |
| let bWarned = step "Rule 34 — B saves and is notified of A's Unsigned work (Rule 21)" bOpen [ act 1 Saves ] | |
| expect "Rule 34 the notice disclosed exactly the one Unsigned TreatmentPlan that exists" | |
| ((noticeAt 1 bWarned |> Option.map _.Disclosed) = Some [ p3Unsigned.Id ]) | |
| // While B deliberates, A saves again: a *newer* Unsigned TreatmentPlan of another User, | |
| // which B's token does not name. | |
| let aSavedMeanwhile = | |
| quiet "Rule 34 — A saves again while B deliberates" bWarned | |
| (launchAs ucA.Login (Some pat3) | |
| @ [ act 2 (Prescribes(OrderContextId "oc-a2")); act 2 Saves ]) | |
| let notifiedAgain = | |
| step "Rule 34 — B returns the token it was given, but the record has moved on" aSavedMeanwhile | |
| [ act 1 Saves ] | |
| expect "Rule 34 the token is honoured for what it disclosed and for nothing newer" | |
| (saw (function UnsignedWorkNotice _ -> true | _ -> false) | |
| && never (function TreatmentPlanCreated _ -> true | _ -> false) | |
| && planCount pat3 notifiedAgain = planCount pat3 aSavedMeanwhile) | |
| let thenAccepted = step "Rule 34 — B returns the fresh token, and the create lands" notifiedAgain [ act 1 Saves ] | |
| expect "Rule 34 a token naming everything outstanding is honoured" | |
| (planCount pat3 thenAccepted = planCount pat3 notifiedAgain + 1 | |
| && (headOf pat3 thenAccepted |> Option.map _.By) = Some ucB) | |
| let _ = | |
| let sid = (sidAt 1 bOpen).Value | |
| let tok = { Sid = sid; Disclosed = [ p3Unsigned.Id ]; Mac = "i-made-this-up" } | |
| let opened = (clientOf 1 bOpen).Value.Opened.Value | |
| step "Rule 34 — B skips the notice by inventing a token" bOpen | |
| [ | |
| fromClient 1 | |
| (SessionRequest(sid, CreateTreatmentPlan(None, workingAt 1 bOpen, None, opened, Some tok))) | |
| ] | |
| expect "Rule 34 a self-made token is treated as none at all: B is notified, not honoured" | |
| (saw (function UnsignedWorkNotice _ -> true | _ -> false) | |
| && never (function TreatmentPlanCreated _ -> true | _ -> false)) | |
| // ── Rule 32 / Guarantee 1: the payload names another Patient ── | |
| let aOnPat2 = quiet "Rule 32 precondition" world (launchAs ucA.Login (Some pat2)) | |
| let wrongPatient = | |
| let sid = (sidAt 1 aOnPat2).Value | |
| let smuggled = | |
| [ { Id = OrderContextId "oc-smuggled"; Patient = Some pat3; Content = "elsewhere"; Stamp = None } ] | |
| let opened = (clientOf 1 aOnPat2).Value.Opened.Value | |
| step "Rule 32 — the payload names a Patient the SessionRecord does not" aOnPat2 | |
| [ fromClient 1 (SessionRequest(sid, CreateTreatmentPlan(None, smuggled, None, opened, None))) ] | |
| expect "Rule 32 the Patient comes from the SessionRecord, and a payload that disagrees is refused" | |
| (saw (function CreateRefused _ -> true | _ -> false) | |
| && planCount pat2 wrongPatient = planCount pat2 aOnPat2 | |
| && planCount pat3 wrongPatient = planCount pat3 aOnPat2) | |
| // ── Rule 35: the payload arrives pre-stamped with another User ── | |
| let preStamped = | |
| let sid = (sidAt 1 aOnPat2).Value | |
| let claimed = | |
| [ | |
| { Id = OrderContextId "oc-1"; Patient = Some pat2; Content = "oc-1/as-saved"; Stamp = Some ucB } | |
| { Id = OrderContextId "oc-new"; Patient = Some pat2; Content = "fresh"; Stamp = Some ucB } | |
| ] | |
| let opened = (clientOf 1 aOnPat2).Value.Opened.Value | |
| step "Rule 35 — the cart arrives stamped with B, in A's Session" aOnPat2 | |
| [ fromClient 1 (SessionRequest(sid, CreateTreatmentPlan(None, claimed, None, opened, None))) ] | |
| let stamps = headOf pat2 preStamped |> Option.map _.Orders |> Option.defaultValue [] | |
| expect "Rule 35 the forged stamps are nowhere: the Server recomputed them against the base" | |
| (stamps |> List.forall (fun o -> o.Stamp <> Some ucB)) | |
| expect "Rule 35 unchanged content keeps the base's stamp; the new one gets the Session's User" | |
| (stamps |> List.exists (fun o -> o.Id = OrderContextId "oc-1" && o.Stamp = Some ucA) | |
| && stamps |> List.exists (fun o -> o.Id = OrderContextId "oc-new" && o.Stamp = Some ucA)) | |
| // ── Rule 36: two creates, both past their Rule 20 read ── | |
| // The single-writer assumption is gone: more than one Server may run, and this is | |
| // what makes that safe. Interleaving the cascades leg by leg is the only way to | |
| // put two creates in flight at once — same messages, different order, which is | |
| // exactly what Rule 36 exists to be safe against. | |
| let raced = | |
| racing "Rule 36 — two Sessions on one Patient, two creates in flight at once" both | |
| [ act 1 Saves; act 2 Saves ] | |
| expect "Rule 36 both got past their Rule 20 read: two AppendIf on the same head" | |
| (countOf (function AppendIf _ -> true | _ -> false) = 2 | |
| && (lastTrace | |
| |> List.choose (function | |
| | { Msg = AppendIf(_, expected, _) } -> Some expected | |
| | _ -> None) | |
| |> List.distinct | |
| |> List.length) = 1) | |
| expect "Rule 36 exactly one landed; the other was refused by the Database" | |
| (countOf (function TreatmentPlanAppended _ -> true | _ -> false) = 1 | |
| && countOf (function AppendRefused _ -> true | _ -> false) = 1 | |
| && planCount pat2 raced = planCount pat2 both + 1) | |
| expect "Rule 36 the loser gets the answer it already understands: take up their version" | |
| (countOf (function CreateBlocked _ -> true | _ -> false) = 1) | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // Consequences — derived from the edges, checked over every scenario | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let consequences () = | |
| printfn "" | |
| printfn "############### Consequences ###############" | |
| // Consequence 1. The LaunchScript learns nothing after the launch. This is not a | |
| // discipline the branches keep — it is the shape of edge C4, which is `=>`. The | |
| // only thing that ever reaches the LaunchScript is the Broker's answer to its own | |
| // request, and the User's trigger. | |
| let toLaunchScript = | |
| allTrace |> List.filter (fun e -> e.To = MainEhrLaunchScript) | |
| expect "C1 nothing reaches the LaunchScript but its own Broker answer and the User's trigger" | |
| (toLaunchScript |> List.forall (fun e -> e.From = Broker || e.From = User)) | |
| expect "C1 in particular, neither the Server nor a Client can ever reach it" | |
| (toLaunchScript | |
| |> List.forall (fun e -> | |
| e.From <> GenPresServer && (match e.From with GenPresClient _ -> false | _ -> true))) | |
| // And the edge table is what says so, not the branches. Edge C4 is `=>`, which | |
| // grants the one direction only, so there is no wire back at all. | |
| expect "C1 the edge table refuses a reply to the LaunchScript outright" | |
| (not (Edges.permits GenPresServer MainEhrLaunchScript) | |
| && not (Edges.permits (GenPresClient(BrowserId 1)) MainEhrLaunchScript)) | |
| // "Any pair without an edge cannot exchange data at all. Edges do not compose — no | |
| // component relays on another's behalf unless stated." | |
| expect "Constraints: a pair without an edge cannot exchange data at all" | |
| (not (Edges.permits MainEhrWorkstation GenPresServer) | |
| && not (Edges.permits GenPresDatabase PatientDataPlatform) | |
| && not (Edges.permits (GenPresClient(BrowserId 1)) Broker) | |
| && not (Edges.permits (GenPresClient(BrowserId 1)) UserRegistry)) | |
| // Consequence 2. The Broker is the only party both the LaunchScript and the Server | |
| // can reach, so it is the sole channel between the EHR side and GenPRES. | |
| let reachableFrom a = | |
| Edges.table | |
| |> List.filter (fun (x, _, _) -> x = a) | |
| |> List.map (fun (_, _, y) -> y) | |
| |> Set.ofList | |
| let shared = Set.intersect (reachableFrom MainEhrLaunchScript) (reachableFrom GenPresServer) | |
| expect "C2 the Broker is the only party both the LaunchScript and the Server can reach" | |
| (shared = Set.ofList [ Broker ]) | |
| // Consequence 3. Only the Broker knows whether a credential was redeemed, and it | |
| // cannot tell the LaunchScript, which has exited. | |
| expect "C3 the Broker's answers go only to the Server, the one party that asked" | |
| (allTrace | |
| |> List.forall (fun e -> | |
| match e.Msg with | |
| | LaunchResolved _ | |
| | LaunchRejected _ -> e.To = GenPresServer | |
| | _ -> true)) | |
| // Consequence 6. The Server cannot reach a Client: edge C5 goes one way only, so | |
| // every Server-to-Client envelope is a reply riding that request's connection. | |
| expect "C6 there is no edge from the Server to a Client, so nothing can be pushed" | |
| (Edges.table | |
| |> List.exists (fun (x, _, y) -> | |
| x = GenPresServer && (match y with GenPresClient _ -> true | _ -> false)) | |
| |> not) | |
| // Rule 11. The SessionId is a bearer credential and never travels in a URL. The | |
| // only message that is a URL is OpenUrl, and it carries a LaunchCredential. | |
| expect "Rule 11 the only thing that ever travels as a URL is a LaunchCredential" | |
| (allTrace | |
| |> List.forall (fun e -> | |
| match e.Msg with | |
| | OpenUrl _ -> e.To |> function GenPresClient _ -> true | _ -> false | |
| | _ -> true)) | |
| // Rule 22. The PIN never leaves GenPRES. GenPRES is the Client, the Server and the | |
| // Database; everything else is outside it. | |
| let outsideGenPres = | |
| Set.ofList | |
| [ | |
| Broker | |
| UserRegistry | |
| PatientDataPlatform | |
| MailService | |
| MainEhrWorkstation | |
| MainEhrLaunchScript | |
| ] | |
| let carriesPin (m: Msg) = | |
| match m with | |
| | ChoosePin _ | SupplyPin _ -> true | |
| | Act(Signs _) -> true | |
| | SessionRequest(_, CreateTreatmentPlan(Some _, _, _, _, _)) -> true | |
| | WriteCredential(_, c) | CredentialWritten(_, c) | PinRemoved(_, c) -> c.Pin.IsSome | |
| | CredentialRead(_, Some c) -> c.Pin.IsSome | |
| | _ -> false | |
| expect "Rule 22 no envelope carrying a PIN ever goes outside GenPRES" | |
| (allTrace | |
| |> List.filter (fun e -> carriesPin e.Msg) | |
| |> List.forall (fun e -> not (outsideGenPres.Contains e.To))) | |
| expect "Rule 22 and the mail that says a PIN changed never carries the PIN itself" | |
| (allTrace | |
| |> List.forall (fun e -> | |
| match e.Msg with | |
| | SendMail(_, what) -> not (what.Contains "9999" || what.Contains "1111") | |
| | _ -> true)) | |
| // Rule 10, over every SessionRecord ever written to the Database in every | |
| // scenario. These two are what the typed state buys: with a bare timestamp, | |
| // "no notice is owed" and "one is owed and not yet given" were the same value, | |
| // and neither could be asserted. | |
| let everyRecordWritten = | |
| allTrace | |
| |> List.choose (fun e -> | |
| match e.Msg with | |
| | WriteSessionRecord r -> Some r | |
| | _ -> None) | |
| expect "Rule 10 a Session the User closed is never owed a notice" | |
| (everyRecordWritten | |
| |> List.forall (fun r -> | |
| match r.State with | |
| | Ended(ClosedByUser, _) -> r.Notice = NotOwed | |
| | _ -> true)) | |
| expect "Rule 10 an open Session owes nothing either: the ending is what creates it" | |
| (everyRecordWritten | |
| |> List.forall (fun r -> r.State <> OpenOrGone || r.Notice = NotOwed)) | |
| expect "Rule 10 nothing is ever Told without an ending that owed it" | |
| (everyRecordWritten | |
| |> List.forall (fun r -> | |
| match r.State, r.Notice with | |
| | Ended(mark, _), Told _ -> SessionRecord.owesNotice mark | |
| | _, Told _ -> false | |
| | _ -> true)) | |
| // Rule 4. Only the Server may redeem a LaunchCredential at the Broker. | |
| expect "Rule 4 every redemption at the Broker came from the Server" | |
| (allTrace | |
| |> List.forall (fun e -> | |
| match e.Msg with | |
| | ResolveLaunch _ -> e.From = GenPresServer && e.To = Broker | |
| | _ -> true)) | |
| // Rule 5. The Role a Session carries is byte-for-byte the registry's answer, never | |
| // synthesised: every launched Session is preceded by a UserResolved carrying the | |
| // very same UserContext. Anonymous opens are excluded — they carry no User at all | |
| // (Rule 13). The type-level half is that LaunchAssertion has no Role field to | |
| // carry one (Concept 3), so the launch could not have supplied it. | |
| let indexed = allTrace |> List.indexed | |
| let resolvedBefore (i: int) (uc: UserContext) = | |
| indexed | |
| |> List.exists (fun (j, e) -> | |
| j < i && (match e.Msg with UserResolved(_, uc', _) -> uc' = uc | _ -> false)) | |
| expect "Rule 5 every Session's Role came from the registry, and came first" | |
| (indexed | |
| |> List.forall (fun (i, e) -> | |
| match e.Msg with | |
| | SessionOpened(_, _, Some uc, _, _, _) -> resolvedBefore i uc | |
| | _ -> true)) | |
| // ── Rule 31, structurally ── | |
| // The Server carries nothing across requests. This is checked after every step of | |
| // every scenario, not sampled: `noteFlight` trips a flag the moment a step ends | |
| // with anything in the in-flight table. | |
| expect "Rule 31 the in-flight table is empty at the end of every scenario step" | |
| (not everCarriedARequest) | |
| // And the type says the same thing: `ServerState` has counters, the two in-flight | |
| // tables and `Up`. There is no field a Session could live in — no cart, no | |
| // opened-with, no last-seen — so "the Server holds no Session state" is not a | |
| // discipline the branches keep but something the state cannot express. | |
| expect "Rule 31 the whole of the work travels with every request (Open Question 3)" | |
| (allTrace | |
| |> List.exists (fun e -> | |
| match e.Msg with | |
| | SessionRequest(_, CreateTreatmentPlan(_, orders, _, _, _)) -> not orders.IsEmpty | |
| | _ -> false)) | |
| // ── Rule 32 ── | |
| // The payload has no User in it to be believed — `SessionCmd` carries orders, data | |
| // and tokens, and a token names a SessionId, not an identity — so the only place a | |
| // TreatmentPlan's `By` can have come from is a SessionRecord the Server had just read. | |
| // That is what this checks: every conditional append is preceded by the read that | |
| // supplied its User. | |
| let readARecordFor (i: int) (uc: UserContext) = | |
| indexed | |
| |> List.exists (fun (j, e) -> | |
| j < i && (match e.Msg with SessionRecordRead(_, Some r) -> r.User = Some uc | _ -> false)) | |
| expect "Rule 32 every TreatmentPlan's User came off a SessionRecord, never off the payload" | |
| (indexed | |
| |> List.forall (fun (i, e) -> | |
| match e.Msg with | |
| | AppendIf(_, _, d) -> readARecordFor i d.By | |
| | _ -> true)) | |
| // ── Rules 33 and 34 ── | |
| expect "Rule 33 every token the Server ever issued verifies, and every stale one was refused honestly" | |
| (allTrace | |
| |> List.forall (fun e -> | |
| match e.Msg with | |
| | SessionOpened(_, _, _, _, _, t) -> Token.verifyOpened t | |
| | TreatmentPlanCreated(_, _, t) -> Token.verifyOpened t | |
| | TreatmentPlanOpened(_, _, t) -> Token.verifyOpened t | |
| | _ -> true)) | |
| expect "Rule 34 every notice the Server ever sent carried a token that verifies" | |
| (allTrace | |
| |> List.forall (fun e -> | |
| match e.Msg with | |
| | UnsignedWorkNotice(_, t) -> Token.verifyNotice t | |
| | _ -> true)) | |
| // ── Rule 36 ── | |
| // There is no message that appends a TreatmentPlan unconditionally: `AppendIf` is the | |
| // only way in, and every one of them states the head its Rule 20 check saw. | |
| let appendIfs = allTrace |> List.filter (fun e -> match e.Msg with AppendIf _ -> true | _ -> false) | |
| let appended = allTrace |> List.filter (fun e -> match e.Msg with TreatmentPlanAppended _ -> true | _ -> false) | |
| expect "Rule 36 every TreatmentPlan that landed came through a conditional append" | |
| (appended.Length <= appendIfs.Length && appended.Length > 0) | |
| expect "Rule 36 and every conditional append went to the Database, the one arbiter" | |
| (appendIfs |> List.forall (fun e -> e.To = GenPresDatabase)) | |
| // ── Rule 9 ── | |
| // Four endings, and a Server restart is not one of them. The type says so — there | |
| // is no `EndMark` for it — and UC-9 ext 1a shows what happens instead. | |
| let marksSeen = | |
| everyRecordWritten | |
| |> List.choose (fun r -> match r.State with Ended(m, _) -> Some m | OpenOrGone -> None) | |
| |> List.distinct | |
| expect "Rule 9 all four endings occur across the run, and nothing else ever ends a Session" | |
| (marksSeen |> List.sort = List.sort [ ClosedByUser; Idle; Superseded; WrongPinLimit ]) | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // Guarantees — what the Rules add up to | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let guarantees () = | |
| printfn "" | |
| printfn "############### Guarantees ###############" | |
| // A record built up over several creates, by two Users, with a block and a | |
| // takeover in the middle of it. | |
| let g0 = quiet "G" world (launchAs ucA.Login (Some pat2)) | |
| let g1 = quiet "G" g0 [ act 1 (Prescribes(OrderContextId "g-1")); act 1 Saves ] | |
| let g2 = quiet "G" g1 [ act 1 (Prescribes(OrderContextId "g-2")); act 1 (Signs pinA) ] | |
| let g3 = quiet "G" g2 (launchAs ucB.Login (Some pat2)) | |
| let g4 = quiet "G" g3 [ act 2 (Prescribes(OrderContextId "g-3")); act 2 (Signs pinB) ] | |
| let record = recordFor pat2 g4 | |
| // ── Guarantee 1: one constant ── | |
| expect "G1 the PatientId is the one thing no TreatmentPlan may change" | |
| (record.Plans |> List.forall (fun s -> s.Patient = pat2)) | |
| expect "G1 and only a launch supplies one, so no hand ever set it (Rules 12, 13, 32)" | |
| (g4.Database.Records | |
| |> Map.forall (fun p r -> r.Plans |> List.forall (fun s -> s.Patient = p))) | |
| // ── Guarantee 2: one version ── | |
| let signedOnes = record.Plans |> List.filter _.Signed | |
| expect "G2 exactly one TreatmentPlan is the visible version: the most recent Signed one (Rules 16, 17)" | |
| ((PatientRecord.latestSigned record |> Option.map _.Id) = (signedOnes |> List.tryHead |> Option.map _.Id)) | |
| expect "G2 nothing else can be opened by another User (Rule 17)" | |
| (record.Plans | |
| |> List.filter (fun s -> Some s.Id <> (PatientRecord.latestSigned record |> Option.map _.Id)) | |
| |> List.forall (fun s -> | |
| // openable by its creator if Unsigned (Rule 18), by nobody else | |
| (record |> PatientRecord.mayOpen ucC.UserId s.Id).IsNone)) | |
| expect "G2 and each User has exactly one starting point (Rule 19)" | |
| ([ ucA; ucB; ucC ] | |
| |> List.forall (fun uc -> (record |> PatientRecord.startsFrom uc.UserId) |> Option.isSome)) | |
| // ── Guarantee 3: carts and one checkout ── | |
| // The cart is private by construction now: it lives in the User's own Client and | |
| // the Server keeps none of it (Rule 31). The checkout is single by construction | |
| // too: the Database arbitrates the append (Rule 36). | |
| expect "G3 signing is the only checkout: every Signed TreatmentPlan came from a create with a PIN" | |
| (signedOnes |> List.forall (fun s -> s.By.Role = Prescriber)) | |
| expect "G3 a Reader never appears as the creator of anything (Roles)" | |
| (g4.Database.Records | |
| |> Map.forall (fun _ r -> r.Plans |> List.forall (fun s -> s.By.Role <> Reader))) | |
| expect "G3 every TreatmentPlan after the first stands on a base (Concept 13)" | |
| (record.Plans | |
| |> List.filter (fun s -> s.No <> TreatmentPlanNo 1) | |
| |> List.forall (fun s -> s.Base.IsSome)) | |
| expect "G3 the two carts never met in the Server: it held neither (Rule 31)" | |
| (g4.GenPres.InFlight.IsEmpty | |
| && (g4.Clients |> Map.exists (fun _ c -> not c.Working.IsEmpty))) | |
| // ── Guarantee 4: audit ── | |
| expect "G4 a Signed TreatmentPlan carries the User who signed it (Concepts 13, 14; Rule 14)" | |
| (signedOnes |> List.forall (fun s -> s.By.UserId = ucA.UserId || s.By.UserId = ucB.UserId)) | |
| expect "G4 every OrderContext in every TreatmentPlan carries the User whose Session last changed it" | |
| (g4.Database.Records | |
| |> Map.forall (fun _ r -> | |
| r.Plans |> List.forall (fun s -> s.Orders |> List.forall (_.Stamp.IsSome)))) | |
| expect "G4 and the stamps are not all the signer's: B's signature kept A's work attributed to A" | |
| (record.Plans | |
| |> List.tryHead | |
| |> Option.map (fun s -> | |
| s.By = ucB | |
| && s.Orders |> List.exists (fun o -> o.Stamp = Some ucA) | |
| && s.Orders |> List.exists (fun o -> o.Stamp = Some ucB)) | |
| |> Option.defaultValue false) | |
| // Append-only: each record is the previous one with something on the front, and | |
| // nothing that was already there is ever touched. | |
| let history = [ recordFor pat2 g0; recordFor pat2 g1; recordFor pat2 g2; recordFor pat2 g4 ] | |
| expect "G4 the record is append-only: every earlier version is a suffix of the later one (Concept 12)" | |
| (history | |
| |> List.pairwise | |
| |> List.forall (fun (earlier, later) -> | |
| later.Plans.Length >= earlier.Plans.Length | |
| && later.Plans |> List.skip (later.Plans.Length - earlier.Plans.Length) | |
| = earlier.Plans)) | |
| // Stated as the claim rather than as a count: every Signed TreatmentPlan that existed | |
| // at any point in the history is still in the record at the end of it. | |
| let everSigned = | |
| history |> List.collect (fun r -> r.Plans |> List.filter _.Signed) |> List.distinct | |
| expect "G4 nothing attested is ever lost: every Signed TreatmentPlan ever made is still there" | |
| (everSigned <> [] | |
| && everSigned |> List.forall (fun s -> record.Plans |> List.contains s)) | |
| // What is not protected: Unsigned work. Superseded, it can never be signed. | |
| expect "G4 what is not protected is Unsigned work — superseded, it can never be signed (Rules 19, 20)" | |
| (record |> PatientRecord.blocking (Some(TreatmentPlanId "plan-0010"))).IsSome | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // THE RUN | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| let runAll () = | |
| uc1 () |> ignore | |
| uc2 () | |
| uc3 () |> ignore | |
| uc4 () |> ignore | |
| uc5 () |> ignore | |
| uc6 () |> ignore | |
| uc7 () |> ignore | |
| uc8 () |> ignore | |
| uc9 () |> ignore | |
| uc10 () |> ignore | |
| uc11 () |> ignore | |
| uc12 () |> ignore | |
| uc13 () |> ignore | |
| tokensAndArbitration () | |
| consequences () | |
| guarantees () | |
| printfn "" | |
| printfn "######################################################################" | |
| printfn $" {checks - failures}/{checks} checks passed" | |
| if failures > 0 then printfn $" {failures} FAILED" | |
| runAll () |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment