Files
ladill-care/docs/layered-clinical-assessment-engine.md
isaacclad 2ce4bc8993
Deploy Ladill Care / deploy (push) Successful in 1m26s
feat(assessments): layered clinical assessment engine end-to-end
Add a template-driven assessment system with universal intake, clinical
pathways, disease instruments (stroke MVP + extended, diabetes, and ten
specialty packs), scoring, patient outcome trends, REST/API + FHIR
export, and Enterprise org-level assessment analytics. Seed packs and
design/licensing docs ship for deploy and pre-GA review.
2026-07-16 22:58:09 +00:00

1469 lines
72 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Layered Clinical Assessment Engine for Ladill Care
| Field | Value |
|-------|-------|
| **Document title** | Layered Clinical Assessment Engine |
| **Author** | Ladill Care Engineering |
| **Date** | 2026-07-16 |
| **Status** | Approved for implementation |
| **Audience** | Senior engineers working in the Ladill Care Laravel codebase |
| **Related surfaces** | Consultations, patients, diagnoses, vitals, investigations, reports |
---
## Overview
Ladill Care today supports patient registration, visits, consultations, free-text symptoms/notes, typed vital signs, free-text diagnoses, lab orders, prescriptions, and billing. What it does **not** yet support is structured clinical assessment across specialties: instruments such as NIHSS, mRS, NYHA, HbA1c foot exams, longitudinal outcome tracking, or automatic activation of disease-specific forms when a diagnosis is recorded.
This design introduces a **layered, metadata-driven clinical assessment engine**. Every patient completes a universal intake (Layer 1). When a clinician records or selects one or more diagnoses, matching **clinical pathways** activate (Layer 2) and load **disease-specific assessment templates** (Layer 3). Independent of disease, **generic outcome measures** (Layer 4) support longitudinal monitoring. The engine is hybrid: hot clinical data (demographics, allergies, vitals, diagnoses, labs) remains typed relational tables; specialty instruments and structured sections not yet modeled become template → question → answer instances.
The goal is to scale to many specialties and comorbidities without schema churn, without disease-specific patient columns, and without forcing disease modules at registration time.
---
## Background & Motivation
### Current state (codebase facts)
Multi-tenant healthcare app (`care.ladill.com`) with `owner_ref` + `organization_id` tenancy, UUIDs on public entities, soft deletes, Blade + Alpine UI, and service-layer business logic.
**Layer 1 is partially present as typed tables:**
| Concern | Table / model | Notes |
|---------|---------------|-------|
| Demographics | `care_patients` / `Patient` | name, DOB, gender, national_id, phone, email, address, city, region |
| Emergency contacts | `care_emergency_contacts` / `EmergencyContact` | |
| Insurance | `care_insurance_policies` / `InsurancePolicy` | |
| Allergies | `care_patient_allergies` / `PatientAllergy` | allergen, severity |
| Chronic conditions | `care_patient_conditions` / `PatientCondition` | free-text condition, not pathway-linked |
| Family history | `care_patient_family_history` / `PatientFamilyHistory` | |
| Vitals | `care_vital_signs` / `VitalSign` | BP, pulse, temp, weight, height, SpO₂, RR — **typed, consultation-scoped** |
| Diagnoses | `care_diagnoses` / `Diagnosis` | optional code + description, primary flag — **free text** |
| Labs | `care_investigation_*` | typed catalog + request + result; `care_investigation_result_values` uses a **single string** `value` column (EAV-like parameters, not typed columns) |
| Consultation narrative | `care_consultations.symptoms`, `.clinical_notes` | free text only |
**Clinical workflow already implemented:**
```
Appointment check-in → Visit → Consultation (draft)
→ Vitals (typed rows)
→ Symptoms / clinical notes (text)
→ Diagnoses (sync-delete-recreate in ConsultationService::syncDiagnoses)
→ Investigations / prescriptions / complete
```
Key entry points:
- UI: `resources/views/care/consultations/show.blade.php`
- Web: `App\Http\Controllers\Care\ConsultationController`
- API: `App\Http\Controllers\Api\ConsultationController`
- Domain: `App\Services\Care\ConsultationService`
- Auth: `CarePermissions` roles (`doctor`, `nurse`, …) + `ScopesToAccount` / `BelongsToOwner`
- Audit: `AuditLogger::record(...)` with actions listed in `config/care.php``audit_actions`
- Reports: `ReportService::clinicalReport` groups diagnosis descriptions only
- Seeders: `database/seeders/` currently has **no** assessment content-pack pattern (greenfield for this engine)
### Pain points
1. **Unstructured HPI** — symptoms are a textarea; no duration, pain score, or systematic functional status.
2. **No specialty instruments** — stroke/diabetes/COPD scoring cannot be captured, scored, or trended.
3. **Diagnosis is dead data for workflow** — recording “Ischaemic stroke” does not surface NIHSS or swallow assessment.
4. **Comorbidity gap**`care_patient_conditions` is a static list; it does not load assessment packs.
5. **Longitudinal gap** — vitals append rows per consultation; there is no first-class QoL, falls, adherence, or readmission outcome series.
6. **Schema risk** — adding NIHSS as columns on patients/consultations would explode tables and break when guidelines change.
### Why now
Ladill Care is positioned for multi-specialty facilities (see `config/care.php` department types: maternity, dental, physiotherapy, etc.). Without a modular assessment engine, each specialty request becomes a one-off schema + UI project. A template-driven engine amortizes that cost.
---
## Goals & Non-Goals
### Goals
1. **Universal first** — register and consult any patient without selecting a specialty module.
2. **Diagnosis-activated pathways** — linking a diagnosis (or explicit clinician selection) activates one or more clinical pathways and their assessment templates.
3. **Comorbidity-safe** — N active pathways ⇒ N concurrent template sets on the same patient/consultation.
4. **Metadata-driven instruments** — new diseases and guideline updates ship as seed/content packs, not migrations of clinical columns.
5. **Hybrid storage** — keep typed tables for demographics, vitals, diagnoses, labs; use template answers for instruments and missing structured universal sections.
6. **Longitudinal by design** — every assessment instance is dated, patient-scoped, optionally consultation/visit-linked, with status lifecycle.
7. **Fit existing patterns**`care_` tables, UUIDs, soft deletes, `owner_ref` on **runtime** data, organization/branch scope, `CarePermissions`, `AuditLogger`, service classes, Blade sections on consultation show.
8. **Incremental delivery** — ship engine + universal template first; pathways; scored stroke MVP (NIHSS + mRS); then more packs.
### Non-Goals
1. **Full ICD-10/SNOMED browser** in v1 — free-text + optional code remains; pathway matching uses configurable code/keyword rules, not a complete terminology server.
2. **Replacing typed vitals or lab catalog** with EAV — those stay relational.
3. **All twelve specialty modules in the first release** — Stroke MVP (NIHSS + mRS) then Diabetes; others follow as content packs.
4. **Treatment plan engine / care pathways as order sets** — assessment only; orders stay prescriptions/investigations.
5. **Offline mobile form runtime** — web + existing API patterns first.
6. **Clinical decision support alerts** (e.g. thrombolysis eligibility) beyond computed scores and basic validation.
7. **Patient self-assessment portal** in v1 (may reuse templates later).
8. **Migrating historical free-text symptoms into structured answers** automatically.
9. **Org-level pathway match_rules overrides** in v1 (platform rules only).
10. **Auto-copy between symptoms textarea and universal intake** in either direction.
---
## Key Decisions
| # | Decision | Rationale |
|---|----------|-----------|
| KD-1 | **Hybrid model**: typed clinical hot path + template-driven instruments | Matches agreed principles; vitals/labs/diagnoses already typed and report-friendly (`ReportService`, consultation UI). Avoids EAV for BP/weight used every visit. |
| KD-2 | **Assessment instances are first-class rows** (`care_assessments`), not JSON blobs on consultation | Enables longitudinal queries, multi-template per visit, draft/complete status, audit subject IDs. |
| KD-3 | **Answers store values in typed columns** (`value_text`, `value_number`, `value_boolean`, `value_date`, `value_json`) keyed by `question_id`; **exactly one authoritative column per answer_type**; unused columns **must be null** | Improves on string-only `care_investigation_result_values.value` for numeric scoring (NIHSS, CAT). Write/read invariants prevent dual-column drift. |
| KD-4 | **Pathways are overlays, not patient types** | Patient registration stays universal (`PatientService` / `_form.blade.php` unchanged for specialty). Pathways attach via `care_patient_pathways`. |
| KD-5 | **Pathway activation is explicit + rule-assisted** | Auto-suggest from diagnosis code/description match; clinician confirms. Avoids silent activation on ambiguous free-text diagnoses. |
| KD-6 | **Catalog is platform-global (no `owner_ref`); runtime always tenant-scoped** | Clinical instruments (NIHSS) are platform-standard. **Intentionally different** from org-scoped `care_investigation_types`. See [Catalog tenancy contract](#catalog-tenancy-contract). Org template forks are **out of v1**. |
| KD-7 | **Score materialization table ships with scoring PR (PR 4), not engine schema PR** | Keeps PR 1 small; scores needed before specialty instruments (KD-7 timing with specialty module). |
| KD-8 | **Link assessments to consultation when captured in-consult; always require `patient_id`** | Supports in-visit workflow and standalone follow-up assessments without open consultation. |
| KD-9 | **Capture gate = route ability + `assertCaptureAllowed` with admin bypass** | Flat abilities cannot express per-template rules alone. Nurses get `assessments.view` + `assessments.capture`; doctors get those + `assessments.manage` + `pathways.manage`. **Facility owners are `hospital_admin`** (`OrganizationResolver::ensureOwnerMember`), not `doctor` — admins must capture disease instruments. Rule: `hospital_admin` / `super_admin` always allowed; else member `role` must be in `template.meta.capture_roles`. See [Permissions](#permissions-carepermissions). |
| KD-10 | **Content packs as versioned JSON under `database/data/assessments/` + seeders** | Reproducible deploys; no admin form builder in v1. Canonical schema required (see [Seed JSON schema](#seedjson-content-pack-schema)). |
| KD-11 | **Do not expand `care_patients` with social/functional columns** | Put structured HPI / social history / functional status into universal assessment templates so they can evolve without migrations. |
| KD-12 | **Diagnoses remain on `care_diagnoses`** | Pathway engine reads diagnoses; does not replace them. Activation snapshots `activation_diagnosis_text` only — **never** diagnosis row FKs (`syncDiagnoses` deletes/recreates IDs every save). |
| KD-13 | **Branch visibility matches consultations** | List/show assessments apply `OrganizationResolver::branchScope` like `ConsultationController::authorizeConsultation`. Branch-scoped members only see assessments for their branch; admins without branch scope see org-wide. Write: `branch_id` from visit (preferred) else `patient.branch_id`. |
| KD-14 | **Completed assessments are immutable in v1** | `PUT` / complete on non-draft → **422**. No amend flow. Soft-delete assessment for administrative removal only (answers hard-owned by assessment; queries always scope non-deleted assessments). |
| KD-15 | **Consultation free-text remains narrative source of truth for HPI** | `care_consultations.symptoms` / `clinical_notes` stay primary narrative for discharge-style notes and existing UI. Universal intake is an **optional structured overlay**. Show both on chart; **no auto-copy either direction** in v1. |
| KD-16 | **Pathway bindings store `template_code` only** (no `template_id` on `care_pathway_templates`) | Resolve `is_current` at assessment start so version bumps do not break pathway rows. Historical assessments keep their own `template_id` FK. |
| KD-17 | **One active pathway per (patient, pathway); one draft per (patient, template, consultation)** | Enforced in transactions with `lockForUpdate`; see [Concurrency & uniqueness](#concurrency--uniqueness). |
| KD-18 | **Plan packaging: engine + disease capture free; advanced analytics Enterprise** | Core assessment engine, universal intake, pathway activation, and disease pack capture are available without Enterprise gate. Org-level outcome trends / advanced assessment analytics are Enterprise-packaged (see Rollout). |
| KD-19 | **Single Stroke pathway; subtype is assessment data (includes TIA)** | TIA, ischaemic, and haemorrhagic are not separate pathways. Capture subtype on stroke clinical instruments / match TIA keywords to the same `stroke` pathway. |
| KD-20 | **Pathways and `care_patient_conditions` stay independent** | Activating a pathway does **not** auto-add or sync chronic condition list rows. Clinicians maintain conditions separately. |
---
## Proposed Design
### Layer model
```mermaid
flowchart TB
subgraph L1["Layer 1 — Universal"]
Demo[Demographics typed]
EC[Emergency / Insurance typed]
All[Allergies / Family / Conditions typed]
UnivTpl[Universal intake template]
Vitals[Vitals typed]
Narrative[Consultation symptoms/notes free text]
end
subgraph L2["Layer 2 — Pathway selection"]
Dx[Persisted diagnoses]
Match[Pathway matcher]
PP[Patient pathways active]
end
subgraph L3["Layer 3 — Disease modules"]
Stroke[Stroke templates]
DM[Diabetes templates]
Other[Other content packs...]
end
subgraph L4["Layer 4 — Outcomes"]
OutTpl[Generic outcome template]
Scores[Score / trend series]
end
Demo --> UnivTpl
Narrative -.->|no auto-copy v1| UnivTpl
UnivTpl --> Dx
Vitals --> Dx
Dx --> Match
Match --> PP
PP --> Stroke
PP --> DM
PP --> Other
Stroke --> OutTpl
DM --> OutTpl
UnivTpl --> OutTpl
OutTpl --> Scores
```
### Clinical workflow (target)
```mermaid
sequenceDiagram
participant R as Reception
participant N as Nurse
participant D as Doctor
participant Eng as AssessmentEngine
participant DB as DB
R->>DB: Register patient (typed demographics)
R->>DB: Check-in visit / appointment
N->>DB: Record vitals (care_vital_signs)
N->>Eng: Start universal intake (capture ability)
Eng->>DB: care_assessments + answers
D->>DB: Save consultation notes + diagnoses
Note over D,Eng: Suggestions use persisted diagnoses only
D->>Eng: GET pathway-suggestions
Eng-->>D: Stroke, Diabetes + match reasons
D->>Eng: Activate pathways (confirm)
Eng->>DB: care_patient_pathways + required drafts
D->>Eng: Complete NIHSS / mRS
Eng->>DB: Answers + scores
D->>DB: Plan (Rx / labs) + complete consultation
Note over Eng,DB: Follow-up: outcome template + disease follow-ups
```
### Architecture (application)
```mermaid
flowchart LR
UI[Blade consultation / patient show]
API[API AssessmentController]
Ctl[Care\\AssessmentController<br/>PathwayController]
Svc[AssessmentService<br/>PathwayService<br/>ScoringService]
Feat[CareFeatures rollout flags]
Match[PathwayMatcher]
Models[(Catalog global<br/>Runtime tenant-scoped)]
Exist[ConsultationService<br/>Patient / Diagnosis / VitalSign]
UI --> Ctl
UI --> Feat
API --> Svc
Ctl --> Svc
Ctl --> Feat
Svc --> Models
Svc --> Match
Exist --> Match
Svc --> Exist
```
Place services under `app/Services/Care/` alongside `ConsultationService`, `InvestigationService`. Controllers under `app/Http/Controllers/Care/` and `Api/`. Follow `ScopesToAccount` + `authorizeAbility` patterns from `ConsultationController`.
### Domain concepts
| Concept | Definition |
|---------|------------|
| **Template** | Versioned form definition (e.g. `universal_intake`, `nihss`, `mrs`, `outcome_core`). |
| **Question** | Field within a template: code, label, answer type, options, validation, scoring weight, section. |
| **Assessment** | Completed or draft instance of a template for a patient at a point in time. |
| **Answer** | Value for one question on one assessment (single typed column authoritative). |
| **Pathway** | Clinical module registry entry (Stroke, Diabetes, …) binding diagnosis match rules to templates by **code**. |
| **Patient pathway** | Active association of a pathway to a patient (onset, status, activating diagnosis text snapshot). |
| **Score** | Materialized instrument total/subscores for an assessment. |
### Catalog tenancy contract
This engine **intentionally diverges** from `care_investigation_types` (which is org-scoped with `owner_ref` + `organization_id`).
| Layer | Tables | Tenancy | Authz |
|-------|--------|---------|-------|
| **Catalog** | `care_assessment_templates`, `care_assessment_questions`, `care_clinical_pathways`, `care_pathway_templates` | **No `owner_ref`**. Platform-global rows only in v1. `organization_id` is **null** and reserved for a future org-fork phase (not used in v1 writes). | Read: any authenticated Care member with `assessments.view` (or consultation context). Write: **not via product UI in v1** — only seeders/migrations/deploy. No `authorizeOwner` on catalog models. |
| **Runtime** | `care_assessments`, `care_assessment_answers`, `care_assessment_scores`, `care_patient_pathways` | Always `owner_ref` + `organization_id` copied from patient (and `branch_id` per KD-13). Models use `BelongsToOwner`. | `authorizeOwner` + org check + branchScope like consultations. |
**Models / traits:**
- Catalog models: **do not** use `BelongsToOwner`.
- Runtime models: **do** use `BelongsToOwner` + soft deletes where specified.
**Canonical catalog queries:**
```php
// Current system template by code (v1: all catalog rows are system)
AssessmentTemplate::query()
->whereNull('organization_id')
->where('code', $code)
->where('is_current', true)
->where('is_active', true)
->whereNull('deleted_at')
->firstOrFail();
// Active pathways for matching
ClinicalPathway::query()
->where('is_active', true)
->whereNull('deleted_at')
->orderBy('sort_order')
->get();
```
**Seeding across tenants:** one shared catalog in the app database; all `owner_ref` tenants read the same NIHSS definition. White-label multi-DB deploys each seed their own catalog (same JSON packs). There is no per-tenant catalog copy in v1.
**Why not `owner_ref` on catalog:** system NIHSS is not owned by any hospital account; putting a seeder `owner_ref` would break `authorizeOwner` for other tenants or force awkward sentinel values.
### Template categories
| `category` | Purpose | Default `meta.capture_roles` |
|------------|---------|------------------------------|
| `universal` | Layer 1 structured intake | `["doctor","nurse"]` |
| `disease` | Layer 3 instruments | `["doctor"]` |
| `outcome` | Layer 4 longitudinal | `["doctor","nurse"]` |
| `screening` | Optional later | `["doctor","nurse"]` |
### Answer types and write/read invariants
Supported `answer_type` values. **Exactly one storage column is authoritative**; on write, all other value columns **must be set to null**.
| Type | Authoritative column | Payload shape | UI control |
|------|----------------------|---------------|------------|
| `text` | `value_text` | string | input |
| `textarea` | `value_text` | string | textarea |
| `number` | `value_number` | numeric | number input |
| `integer` | `value_number` | integer (stored as decimal) | number input |
| `boolean` | `value_boolean` | bool | checkbox |
| `date` | `value_date` | `Y-m-d` | date |
| `datetime` | `value_date` | ISO datetime | datetime |
| `single_choice` | `value_text` | option `code` string | radio/select |
| `multi_choice` | `value_json` | array of option codes | checkboxes |
| `scale` | `value_number` | number within min/max | Likert / 010 |
| `score_item` | `value_number` | numeric score (from choice or direct) | scored choice (NIHSS item) |
| `calculated` | `value_number` | **not client-writable** in v1 | read-only display |
**Normalization (`AssessmentService::normalizeValue`):**
1. Validate raw payload against type + `validation` / `options`.
2. Build row with **only** the authoritative column set; explicitly null the others.
3. For `score_item` + `single_choice`-style choices: client may send option `code`; server maps `options.choices[].score``value_number`, and may also store code in `value_text` **only if** type were `single_choice`. For pure `score_item`, store score in `value_number` only (code optional in `value_json` as `{"code":"1"}` if audit of selection needed — v1: store score only in `value_number`).
4. For `multi_choice`: scores are **not** auto-summed unless `scoring_strategy` handles them (v1 disease instruments use `score_item`, not multi_choice, for scored totals).
5. **`calculated` fields (v1 deferred for formulas):** if present, ignore client input; recompute only if `options.formula` is absent — **v1 ships no calculated questions**. Reserve type for PR later; seed packs must not use `calculated` until formula support lands.
**Read path:** UI and scoring always read the authoritative column for the questions `answer_type`; never fall back to another column.
Options JSON on questions:
```json
{
"choices": [
{"code": "0", "label": "No symptoms at all", "score": 0},
{"code": "1", "label": "No significant disability", "score": 1}
],
"min": 0,
"max": 6,
"unit": null
}
```
### Scoring contract
`ScoringService` runs on **complete** (and optionally `preview` on draft save for live totals — preview does not persist).
| `scoring_strategy` | Behavior |
|--------------------|----------|
| `null` / empty | No score row required (e.g. free-text heavy forms). Complete still validates required questions. |
| `sum_items` | Sum `value_number` for all questions with `answer_type = score_item` (and optional `score_key` buckets into `subscores`). `max_score` from sum of each items `options.max` or max choice score. |
| `single_value` | Exactly one scored question (usually `score_item` or `scale`); `total_score` = that `value_number`. Used for mRS. |
| `custom:{Handler}` | Map to `App\Services\Care\Scoring\{Handler}` implementing `ScoresAssessment` interface (`score(Assessment): array{total, max, subscores, severity_label}`). Register via strategy string only — no dynamic class from user input. |
**Complete-time validation:**
1. All `is_required` questions have non-null authoritative values.
2. If strategy is `sum_items` or `single_value`, all `score_item` questions required for the instrument must be present (treat missing as 422).
3. On success: upsert `care_assessment_scores`; audit `assessment.completed`.
4. **Idempotent complete:** if already `completed`, return 422 (no re-complete) per KD-14.
5. Scoring failure: `Log::warning` with `template_code`, `assessment_uuid`, exception message; do not complete; return 422.
### Pathway matching (detailed)
`PathwayMatcher` input: **persisted** diagnosis rows only (`code`, `description`) — see [Pathway suggestion UX](#pathway-suggestion-ux-timing).
Rules on `care_clinical_pathways.match_rules`:
```json
{
"icd_prefixes": ["I60", "I61", "I62", "I63", "I64", "G45"],
"keywords": ["stroke", "cva", "tia", "transient ischaemic attack", "transient ischemic attack", "cerebrovascular", "hemiplegia", "ischaemic stroke", "ischemic stroke"],
"exclude_keywords": ["family history"]
}
```
TIA matches the same `stroke` pathway (KD-19); subtype is not a separate pathway.
**Algorithm (v1, exact):**
```
normalize(s) = lowercase(trim(s)); collapse internal whitespace; ASCII-fold optional (v1: mb_strtolower only)
for each active pathway P:
best_rank = none
best_reason = null
for each diagnosis D:
code_n = normalize(D.code) without dots for prefix compare? → strip non-alnum for prefix: "I63.9" → "I639" then prefix match on stripped prefixes
Actually v1: strip dots from code: "I63.9" → "I639"; prefixes stored without dots "I63" matches startswith
desc_n = normalize(D.description)
if any exclude_keyword in desc_n as substring → skip this diagnosis for P
if any icd_prefix where stripped_code starts with stripped_prefix → rank=100, reason="icd_prefix:{prefix}"
else if any keyword where keyword is substring of desc_n → rank=50, reason="keyword:{keyword}"
keep max rank for P across diagnoses
if best_rank: include P once with rank + reason
sort by rank desc, then pathway.sort_order
return unique pathways (one entry per pathway_id)
```
**Examples:**
| Diagnosis | Expected |
|-----------|----------|
| code `I63.9`, desc empty | Match stroke (icd_prefix I63), rank 100 |
| code empty, desc `Ischaemic stroke` | Match stroke (keyword), rank 50 |
| desc `History unclear` | No match |
| desc `Family history of stroke` with exclude `family history` | No match if exclude_keywords configured |
**Org overrides:** out of scope v1 (Non-Goal 9). Platform `match_rules` only.
**API suggestion payload includes match reason** for UI chips (“Matched ICD I63”, “Matched keyword: stroke”).
### Pathway suggestion UX timing
Diagnoses exist in DB only after `ConsultationService::save``syncDiagnoses` (delete-recreate). Alpine form state is **not** the source for server suggestions.
**v1 rules:**
1. **Server suggestions (`GET .../pathway-suggestions`)** use **persisted** diagnoses on the consultation only. After Save, show section refreshes (or client refetches). Empty if no diagnoses saved yet.
2. **Optional UX:** button **“Save diagnoses & suggest pathways”** that POSTs consultation update then redirects/refetches suggestions (single CTA). Preferred in consultation UI copy.
3. **Optional client preview (non-authoritative):** embed active pathway keyword/prefix lists once on page load for live Alpine preview of unsaved diagnosis rows; label as “Preview — save to confirm”. Must not activate pathways from preview alone.
4. **Activation snapshot:** store `activation_diagnosis_text` (concat descriptions/codes at activate time). **Never** store diagnosis row IDs (IDs are unstable under `syncDiagnoses`).
### Consultation UI integration
Extend `resources/views/care/consultations/show.blade.php` with new sections (same card pattern as vitals/diagnoses), gated by `CareFeatures`:
1. **Universal assessment** — link/form for incomplete universal intake for this visit (`$canCaptureAssessment`).
2. **Active pathways** — chips; “Add clinical pathway” (`pathways.manage`).
3. **Suggested pathways** — from **saved** diagnoses + match reasons; empty state: “Save diagnoses to see suggestions”.
4. **Disease assessments** — drafts/completes for this consultation + patient; “Start NIHSS” if capture_roles allow.
5. **Outcomes** — optional follow-up CTA.
UI flags (mirror vitals split in `ConsultationController`):
```php
$canViewAssessments = permissions->can($member, 'assessments.view');
$canCaptureAssessment = permissions->can($member, 'assessments.capture')
|| permissions->can($member, 'assessments.manage');
$canManagePathways = permissions->can($member, 'pathways.manage');
// Per-template start/save: AssessmentService::assertCaptureAllowed (admin bypass + capture_roles)
```
Patient chart: timeline of assessments (branch-scoped per KD-13) + active pathways + latest scores. Free-text symptoms **and** latest universal intake shown as separate cards (KD-15).
**Routes (authoritative list — no DELETE for pathways; use deactivate):**
```
GET /patients/{patient}/assessments
GET /patients/{patient}/assessments/create
POST /patients/{patient}/assessments
POST /consultations/{consultation}/assessments
GET /assessments/{assessment}
PUT /assessments/{assessment}
POST /assessments/{assessment}/complete
POST /assessments/{assessment}/cancel
GET /patients/{patient}/pathways
POST /patients/{patient}/pathways
POST /patients/{patient}/pathways/{patientPathway}/deactivate
GET /consultations/{consultation}/pathway-suggestions
```
This mirrors lab requests (`care.lab.requests.store`) as a separate POST from consultation save.
### Hook points in existing services
| Location | Change |
|----------|--------|
| `ConsultationService::save` | No silent pathway activation. Optional: controller after save redirects with flash “diagnoses saved — review pathway suggestions”. |
| `ConsultationService::complete` | Soft warning only if flag `assessment_required_on_complete` (default false) — non-blocking in v1. |
| `Patient` model | Relations: `pathways()`, `assessments()`. |
| `Consultation` model | Relation: `assessments()`. |
| `CarePermissions` | Abilities: `assessments.view`, `assessments.capture`, `assessments.manage`, `pathways.manage`. |
| `config/care.php` | Status enums, audit actions, template categories. |
| `database/seeders/DatabaseSeeder.php` | Call assessment/pathway seeders in deploy path (PR 3+). |
### Dynamic form rendering
v1: Blade partial `resources/views/care/assessments/_form.blade.php` switches on `answer_type`. Alpine for multi-item instruments. No SPA.
```php
// Pseudocode — AssessmentService::saveAnswers
abort_unless($assessment->status === Assessment::STATUS_DRAFT, 422);
// hospital_admin/super_admin always allow; else role ∈ capture_roles
$this->assertCaptureAllowed($member, $assessment->template);
foreach ($template->questions as $question) {
if ($question->answer_type === 'calculated') {
continue; // v1 unused
}
$raw = $payload[$question->code] ?? null;
$this->validateAnswer($question, $raw);
AssessmentAnswer::updateOrCreate(
['assessment_id' => $assessment->id, 'question_id' => $question->id],
array_merge(
['owner_ref' => $ownerRef],
$this->normalizeValue($question, $raw), // sets one column, nulls others
),
);
}
```
### Versioning templates
When NIHSS items change:
1. Insert new `care_assessment_templates` row with same `code`, incremented `version`, `is_current = true`; mark previous `is_current = false`.
2. Historical assessments keep FK to the old template version (immutable definition).
3. New assessments resolve via `template_code``is_current` (pathway bindings never store versioned `template_id`).
4. Unit test required: bump version; pathway binding still resolves; new start uses new version; old assessment still loads old questions.
### Concurrency & uniqueness
**Active patient pathway**
- Service: `DB::transaction` + `PatientPathway::where(patient, pathway)->lockForUpdate()`.
- If row `status = active` exists → return existing (idempotent activate) or 422 “already active” (prefer **idempotent return** of existing active row).
- If `resolved` / `inactive` → create **new** row with `active` (history preserved) OR reactivate same row — v1: **create new active row** after setting old to stay resolved/inactive (audit trail). Enforce at most one `active` via transaction check (not partial unique index in MySQL without workarounds).
- Optional DB aid: generated column `active_pathway_key` = `patient_id` when status=active else NULL + unique `(active_pathway_key, pathway_id)` if MySQL version supports; otherwise service lock is sufficient for v1.
**Draft assessments**
- v1: **at most one draft** per `(patient_id, template_id, consultation_id)` when `consultation_id` present; when no consultation, at most one draft per `(patient_id, template_id)` with `consultation_id` null and `status=draft`.
- `start()`: if draft exists, **return existing draft** (idempotent) instead of creating duplicate.
- Multiple **completed** assessments of same template over time are allowed (longitudinal).
### Soft-delete / cancel lifecycle
| Status / action | Behavior |
|-----------------|----------|
| `draft` | Editable via PUT; completeable; **cancellable** via `POST .../cancel``status=cancelled` (not soft-deleted). |
| `completed` | Immutable; PUT/complete/cancel → **422**. Soft-delete only via admin tooling (out of product UI v1) if legally required. |
| `cancelled` | Terminal for that instance; excluded from “open assessments” lists; retained for audit. |
| Soft-delete assessment | Answers remain (FK cascade only on hard delete). Default global scope / queries: `Assessment::query()` excludes soft-deleted. **Never** list answers without joining non-deleted assessment. Hard delete only in tests. |
| Answers | No soft deletes; lifetime bound to assessment row. |
Amend of completed assessments: **out of v1** (Open Question remains for product later; KD-14 locks complete).
---
## API / Interface Changes
### Web routes (additions to `routes/web.php` inside `care.setup` group)
| Method | Route name | Ability |
|--------|------------|---------|
| GET `/patients/{patient}/assessments` | `care.assessments.index` | `assessments.view` |
| GET `/assessments/{assessment}` | `care.assessments.show` | `assessments.view` |
| GET `/patients/{patient}/assessments/create` | `care.assessments.create` | `assessments.capture` or `assessments.manage` |
| POST `/patients/{patient}/assessments` | `care.assessments.store` | `assessments.capture` or `assessments.manage` |
| POST `/consultations/{consultation}/assessments` | `care.consultations.assessments.store` | `assessments.capture` or `assessments.manage` |
| PUT `/assessments/{assessment}` | `care.assessments.update` | `assessments.capture` or `assessments.manage` |
| POST `/assessments/{assessment}/complete` | `care.assessments.complete` | `assessments.capture` or `assessments.manage` |
| POST `/assessments/{assessment}/cancel` | `care.assessments.cancel` | `assessments.capture` or `assessments.manage` |
| GET `/patients/{patient}/pathways` | `care.pathways.index` | `assessments.view` |
| POST `/patients/{patient}/pathways` | `care.pathways.store` | `pathways.manage` |
| POST `/patients/{patient}/pathways/{patientPathway}/deactivate` | `care.pathways.deactivate` | `pathways.manage` |
| GET `/consultations/{consultation}/pathway-suggestions` | `care.consultations.pathway-suggestions` | `consultations.view` |
Route model binding: assessments use `uuid` (`getRouteKeyName()`), consistent with `Consultation`, `Patient`, `Visit`.
### Request / response contracts
#### Public ID convention (web + API)
Care public routes bind consultations, visits, patients, and assessments by **UUID** (`getRouteKeyName()`). Request bodies that reference those entities **must accept UUID strings**, never internal bigint primary keys.
| Public field | Resolves to |
|--------------|-------------|
| `consultation` route param / `consultation_uuid` body | `care_consultations.id` FK |
| `visit_uuid` body (optional) | `care_visits.id` FK |
| `patient` route param | `care_patients.id` FK |
| `assessment` route param | `care_assessments.id` |
Controllers resolve UUID → model with tenant/branch checks, then pass integer FKs into services. Internal service methods may use integer IDs; **HTTP contracts never expose integer FKs as client-facing identifiers**.
#### `POST /patients/{patient}/assessments` (or consultation-scoped store)
```json
{
"template_code": "universal_intake",
"consultation_uuid": null,
"visit_uuid": null,
"patient_pathway_uuid": null
}
```
Consultation-scoped route `POST /consultations/{consultation}/assessments` takes the consultation from the path (UUID route binding); body needs only `template_code` (+ optional pathway uuid).
**Responses:**
| Code | When |
|------|------|
| 201 | Created (or 200 if idempotent existing draft returned — document as 200 with existing uuid) |
| 403 | Missing ability or `assertCaptureAllowed` denies member role |
| 404 | Patient/consultation wrong tenant/branch or unknown UUID |
| 422 | Template inactive / not current / unknown code |
#### `PUT /assessments/{assessment}`
```json
{
"answers": {
"chief_complaint": "Sudden right weakness",
"pain_score": 3,
"smoking_status": "former"
},
"notes": "Optional free text"
}
```
Answers keyed by **question code**. Partial updates allowed (only keys present are written). Omit key → leave previous answer unchanged; send `null` → clear if question not required (required null → 422 on complete, not necessarily on draft save).
**Responses:** 200 OK; **422** if not draft; **403** `assertCaptureAllowed`; **404** tenant.
#### `POST /assessments/{assessment}/complete`
Empty body or `{}`. Validates required + scoring; materializes score.
**Responses:** 200 with assessment + score; **422** validation/scoring/not draft/already completed; **403** `assertCaptureAllowed`.
#### `POST /assessments/{assessment}/cancel`
Draft only → `cancelled`. **422** if completed.
#### `GET /consultations/{consultation}/pathway-suggestions`
```json
{
"data": [
{
"pathway_code": "stroke",
"pathway_name": "Stroke",
"rank": 100,
"match_reason": "icd_prefix:I63",
"already_active": false
}
]
}
```
#### `POST /patients/{patient}/pathways`
```json
{
"pathway_code": "stroke",
"consultation_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"activation_diagnosis_text": "I63.9 Ischaemic stroke"
}
```
`consultation_uuid` is optional; when present, resolve to consultation (owned + org/branch scoped) and store internal `activation_consultation_id` FK. Do **not** accept integer `consultation_id` from clients.
Creates patient pathway + required draft assessments for `is_required_on_activation` templates (resolve current template by code). Does **not** create or update `care_patient_conditions` (KD-20).
#### `listForPatient` filters
`template_code`, `status` (`draft|completed|cancelled`), `category`, `from`, `to` (assessed_at), `per_page` (default 20). Always branch-scoped per KD-13.
### Service interfaces
```php
// app/Services/Care/AssessmentService.php
class AssessmentService
{
public function start(
Patient $patient,
string $templateCode, // resolves is_current system template
string $ownerRef,
?Member $member,
array $context = [], // consultation?, visit?, patient_pathway? (models or internal ids after controller resolve), actor
): Assessment;
/** @param array<string, mixed> $answers keyed by question code */
public function saveAnswers(
Assessment $assessment,
string $ownerRef,
array $answers,
?Member $member = null,
?string $actorRef = null,
): Assessment;
public function complete(Assessment $assessment, string $ownerRef, ?Member $member = null, ?string $actorRef = null): Assessment;
public function cancel(Assessment $assessment, string $ownerRef, ?Member $member = null, ?string $actorRef = null): Assessment;
public function listForPatient(Patient $patient, string $ownerRef, array $filters = [], ?int $branchScope = null): LengthAwarePaginator;
}
// app/Services/Care/PathwayService.php
class PathwayService
{
/** @param iterable<Diagnosis|array{code?:string,description?:string}> $diagnoses */
public function suggest(iterable $diagnoses): Collection; // unique pathways + rank + reason
public function activate(
Patient $patient,
ClinicalPathway $pathway,
string $ownerRef,
array $context = [], // consultation? (model after UUID resolve), activation_diagnosis_text, actor
): PatientPathway;
public function deactivate(PatientPathway $patientPathway, string $ownerRef, ?string $actorRef = null): PatientPathway;
public function activeFor(Patient $patient): Collection;
}
// app/Services/Care/ScoringService.php
class ScoringService
{
public function materialize(Assessment $assessment): AssessmentScore;
/** @return array{total: ?float, max: ?float, subscores: array, severity_label: ?string} */
public function preview(Assessment $assessment): array;
}
// app/Services/Care/CareFeatures.php (rollout flags — NOT PlanService)
class CareFeatures
{
public function enabled(Organization $organization, string $flag): bool;
// keys under settings.rollout.*
}
```
### Permissions (`CarePermissions`)
| Ability | Roles (v1) | Purpose |
|---------|------------|---------|
| `assessments.view` | doctor, nurse, hospital_admin, super_admin | Read assessments/pathways |
| `assessments.capture` | doctor, nurse, hospital_admin, super_admin | Start/save/complete/cancel (subject to `assertCaptureAllowed`) |
| `assessments.manage` | doctor, hospital_admin, super_admin | Route-level capture access (same start/save/complete endpoints as capture) **plus** future admin ops; does **not** alone bypass template rules — service still runs `assertCaptureAllowed` |
| `pathways.manage` | doctor, hospital_admin, super_admin | Activate/deactivate pathways |
**Why admin bypass matters:** `OrganizationResolver::ensureOwnerMember` creates the facility owner as **`hospital_admin`**, not `doctor`. Small clinics often have a single clinician-owner on that role. Disease templates seed `capture_roles: ["doctor"]` for clinical scope — without an explicit admin exception, owners could activate stroke pathways but get **403** on NIHSS/mRS.
**Enforcement layers (both required):**
1. **Route/controller:** member must have `assessments.capture` **or** `assessments.manage` (or `assessments.view` for reads; `pathways.manage` for pathway mutations).
2. **Service — single rule for all start/save/complete/cancel:**
```
assertCaptureAllowed(Member $member, AssessmentTemplate $template):
if member is null → 403
if member.role in {hospital_admin, super_admin} → allow
roles = template.meta.capture_roles
?? default_capture_roles(template.category)
// universal/outcome/screening → ["doctor","nurse"]
// disease → ["doctor"]
if member.role ∈ roles → allow
else → 403
```
Notes:
- **`hospital_admin` / `super_admin` always pass** capture checks (owner/admin clinicians). They still need a route ability (`capture` or `manage`); both roles have `*` in `CarePermissions` today, so that is satisfied.
- **`assessments.manage` does not invent a second capture matrix** — it only unlocks the same endpoints as `capture` for roles that hold manage (doctor already holds both). Template scope is **only** decided by `assertCaptureAllowed` above.
- Disease seeds keep `capture_roles: ["doctor"]`; nurses remain **403** on NIHSS; doctors **201**; hospital_admin **201** via admin branch (not by expanding seed lists to every admin role).
**Feature tests required:**
| Actor role | Template | Expected |
|------------|----------|----------|
| nurse | `universal_intake` | 201 |
| nurse | `nihss` | 403 |
| doctor | `nihss` | 201 |
| hospital_admin | `nihss` | 201 |
| super_admin | `nihss` | 201 |
### Audit actions (add to `config/care.php`)
```
assessment.started
assessment.updated
assessment.completed
assessment.cancelled
pathway.activated
pathway.deactivated
```
Metadata: subject ids, `template_code`, `pathway_code`**not** full answer payloads.
### JSON API (PR 9)
Mirror web under `routes/api.php` with `ScopesApiToAccount`. OpenAPI: `docs/openapi/care.yaml`. Tenant/branch tests parallel `CareLabTest` patterns.
### Consultation payload (unchanged)
`ConsultationController::validatedConsultationData` remains symptoms/notes/vitals/diagnoses/documents. Assessments are separate resources.
---
## Data Model Changes
### Entity relationship
```mermaid
erDiagram
care_patients ||--o{ care_assessments : has
care_consultations ||--o{ care_assessments : context
care_visits ||--o{ care_assessments : context
care_assessment_templates ||--o{ care_assessment_questions : defines
care_assessment_templates ||--o{ care_assessments : instances
care_assessments ||--o{ care_assessment_answers : has
care_assessment_questions ||--o{ care_assessment_answers : answers
care_assessments ||--o| care_assessment_scores : score
care_clinical_pathways ||--o{ care_pathway_templates : includes
care_patients ||--o{ care_patient_pathways : enrolled
care_clinical_pathways ||--o{ care_patient_pathways : activated
```
Note: **no FK** from `care_pathway_templates` to `care_assessment_templates` — linkage by `template_code` string (KD-16).
### New tables
#### Catalog tenancy (summary)
- Catalog: **no `owner_ref`**; platform-global; v1 no org clones.
- Runtime: always `owner_ref` + org (+ branch on assessments).
#### `care_assessment_templates`
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid unique | public id |
| organization_id | FK nullable | **v1 always NULL** (system). Reserved for future forks. |
| code | string | e.g. `nihss`, `universal_intake` |
| name | string | |
| category | string | universal / disease / outcome / screening |
| description | text nullable | |
| version | unsigned int | default 1 |
| is_current | boolean | |
| scoring_strategy | string nullable | `sum_items`, `single_value`, `custom:HandlerName` |
| meta | json nullable | `capture_roles`, estimated_minutes, specialty, attribution |
| is_active | boolean | |
| timestamps | | |
| softDeletes | | |
**Uniqueness (v1):**
- Application invariant: for system rows (`organization_id IS NULL`), unique `(code, version)`.
- DB: unique index on `(code, version)` **valid in v1 because only system rows exist** and org clones are **out of scope**.
- **Future org forks (not v1):** use separate namespace — e.g. code prefix `org_{id}_nihss` **or** unique `(organization_id, code, version)` with sentinel `organization_id = 0` for system if MySQL NULL uniqueness is a problem. Documented so implementers do not invent conflicting clones mid-flight.
- Also enforce at most one row with `(code, is_current=true, organization_id null)` in service/seeder.
#### `care_assessment_questions`
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| template_id | FK cascade | |
| code | string | stable within template version |
| section | string nullable | |
| label | string | |
| help_text | text nullable | |
| answer_type | string | see answer types |
| options | json nullable | choices, min/max, unit |
| is_required | boolean | |
| sort_order | unsigned int | |
| score_key | string nullable | subscore bucket |
| validation | json nullable | min, max, regex |
| timestamps | | |
| unique | (template_id, code) | |
#### `care_assessments`
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid unique | route key |
| owner_ref | string indexed | from patient |
| organization_id | FK | denormalized |
| branch_id | FK nullable | visit preferred else patient.branch_id |
| patient_id | FK cascade | |
| template_id | FK restrict | frozen version |
| consultation_id | FK nullOnDelete | |
| visit_id | FK nullOnDelete | |
| patient_pathway_id | FK nullOnDelete | |
| practitioner_id | FK nullable | |
| status | string | `draft`, `completed`, `cancelled` |
| assessed_at | timestamp nullable | clinical time (default now on complete) |
| completed_at | timestamp nullable | |
| completed_by | string nullable | |
| started_by | string nullable | |
| notes | text nullable | |
| timestamps | | |
| softDeletes | | |
| indexes | (patient_id, assessed_at), (consultation_id), (owner_ref, status), (template_id, status), (patient_id, template_id, status) | |
#### `care_assessment_answers`
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| owner_ref | string indexed | |
| assessment_id | FK cascade | |
| question_id | FK restrict | |
| value_text | text nullable | |
| value_number | decimal(12,4) nullable | |
| value_boolean | boolean nullable | |
| value_date | datetime nullable | |
| value_json | json nullable | |
| timestamps | | |
| unique | (assessment_id, question_id) | |
No soft deletes on answers.
#### `care_assessment_scores` (PR 4 migration)
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| owner_ref | string indexed | |
| assessment_id | FK cascade unique | |
| template_code | string indexed | |
| total_score | decimal(12,4) nullable | |
| max_score | decimal(12,4) nullable | |
| subscores | json nullable | |
| severity_label | string nullable | |
| computed_at | timestamp | |
| timestamps | | |
| index | (owner_ref, template_code, computed_at) | |
#### `care_clinical_pathways`
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid unique | |
| code | string unique | `stroke`, `diabetes`, … |
| name | string | |
| description | text nullable | |
| match_rules | json | icd_prefixes, keywords, exclude_keywords |
| is_active | boolean | |
| sort_order | unsigned int | |
| meta | json nullable | icon, color |
| timestamps | | |
| softDeletes | | |
No `owner_ref` (catalog).
#### `care_pathway_templates`
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| pathway_id | FK cascade | |
| template_code | string | **only** — resolve `is_current` at start (KD-16) |
| is_required_on_activation | boolean | create draft when pathway activated |
| phase | string | `acute`, `follow_up`, `any` |
| sort_order | unsigned int | |
| unique | (pathway_id, template_code) | |
**No `template_id` column.**
#### `care_patient_pathways`
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid unique | |
| owner_ref | string indexed | |
| organization_id | FK | |
| patient_id | FK cascade | |
| pathway_id | FK restrict | |
| status | string | `active`, `resolved`, `inactive` |
| activated_at | timestamp | |
| activated_by | string nullable | |
| activation_consultation_id | FK nullable | |
| activation_diagnosis_text | string nullable | snapshot — **not** diagnosis FK |
| resolved_at | timestamp nullable | |
| notes | text nullable | |
| timestamps | | |
| softDeletes | | |
| index | (patient_id, status), (patient_id, pathway_id, status) | |
### What stays typed (no change)
- `care_patients`, emergency contacts, insurance, allergies, family history, conditions
- `care_vital_signs`
- `care_diagnoses`
- investigation catalog/results (string `value` on result values unchanged)
- prescriptions, bills
### Migration strategy
1. PR 1: templates, questions, assessments, answers (no scores).
2. PR 4: `care_assessment_scores` + scoring service.
3. PR 5: pathway tables.
4. Seed via `database/seeders/*` + JSON packs; wire into `DatabaseSeeder` / deploy runbook.
5. No backfill of historical consultations.
6. Rollback: feature flag off; drop tables only if zero production runtime rows.
### Storage / load estimates
| Assumption | Value |
|------------|-------|
| Active patients per org | 5,00050,000 |
| Assessments per patient-year | 420 |
| Questions per instrument | 520 (NIHSS ~15) |
| Answer rows per assessment | ≈ question count |
| Growth | ~1M answer rows / large org / year |
---
## Seed/JSON content pack schema
**Path convention:** `database/data/assessments/{code}.v{version}.json`
**Seeder:** `AssessmentTemplateSeeder` upserts by `(code, version)`; sets `is_current` per pack; loads questions replacing by template_id. `ClinicalPathwaySeeder` loads pathways + `pathway_templates` bindings.
**Root object:**
```json
{
"template": {
"code": "mrs",
"name": "Modified Rankin Scale",
"category": "disease",
"version": 1,
"is_current": true,
"is_active": true,
"scoring_strategy": "single_value",
"description": "Global disability scale after stroke (06).",
"meta": {
"capture_roles": ["doctor"],
"specialty": "neurology",
"estimated_minutes": 2,
"attribution": "Modified Rankin Scale — use under applicable clinical/educational license; verify commercial redistribution rights before packaging."
}
},
"questions": [
{
"code": "mrs_score",
"section": "scale",
"label": "Modified Rankin Scale score",
"help_text": "Select the grade that best describes the patient.",
"answer_type": "score_item",
"is_required": true,
"sort_order": 1,
"score_key": "total",
"options": {
"min": 0,
"max": 6,
"choices": [
{"code": "0", "label": "No symptoms at all", "score": 0},
{"code": "1", "label": "No significant disability despite symptoms", "score": 1},
{"code": "2", "label": "Slight disability", "score": 2},
{"code": "3", "label": "Moderate disability", "score": 3},
{"code": "4", "label": "Moderately severe disability", "score": 4},
{"code": "5", "label": "Severe disability", "score": 5},
{"code": "6", "label": "Dead", "score": 6}
]
},
"validation": {"min": 0, "max": 6}
}
]
}
```
**Pathway pack** (`database/data/pathways/{code}.json`):
```json
{
"pathway": {
"code": "stroke",
"name": "Stroke",
"match_rules": {
"icd_prefixes": ["I60", "I61", "I62", "I63", "I64", "G45"],
"keywords": ["stroke", "cva", "tia", "transient ischaemic attack", "transient ischemic attack", "cerebrovascular", "hemiplegia", "ischaemic stroke", "ischemic stroke"],
"exclude_keywords": ["family history"]
},
"is_active": true,
"sort_order": 10
},
"templates": [
{"template_code": "nihss", "is_required_on_activation": true, "phase": "acute", "sort_order": 1},
{"template_code": "mrs", "is_required_on_activation": true, "phase": "acute", "sort_order": 2}
]
}
```
TIA diagnoses match this same `stroke` pathway (KD-19); subtype is recorded on assessment, not via a second pathway code.
**Content-pack PR requirements:**
1. JSON conforming to schema above.
2. Golden unit tests for scoring (e.g. mRS 3 → total 3; NIHSS fixture answers → known total).
3. Attribution/licensing note in `meta.attribution` (Open Question: commercial use of instrument IP — legal review before GA of each pack).
4. Seeder registration in `DatabaseSeeder` or documented `php artisan db:seed --class=...` in deploy runbook.
---
## Universal template content (Layer 1 structured)
Seed template `code = universal_intake` (illustrative sections):
**Presenting complaint:** `chief_complaint` (textarea, required), `hpi`, `duration_value` + `duration_unit`, `pain_score` (scale 010), `current_symptoms`.
**Social history:** `smoking_status`, `smoking_pack_years`, `alcohol_use`, `occupation`.
**Functional status:** `mobility`, `communication`, `vision`, `hearing`, `feeding`, `continence`.
**Baseline labs:** optional `baseline_labs_summary` only; prefer investigation results.
Demographics/allergies/vitals remain on existing typed UI.
**Dual narrative (KD-15):** consultation `symptoms` / `clinical_notes` remain the narrative source of truth for free-text clinical story. Universal intake does not replace or auto-sync them. Chart displays both when present.
---
## Disease module content packs (Layer 3)
### Stroke (`pathway.code = stroke`)
**MVP (PR 6)** — production vertical slice:
| Template code | Instruments | Required on activation |
|---------------|-------------|------------------------|
| `nihss` | NIH Stroke Scale items + total (`sum_items`) | yes |
| `mrs` | Modified Rankin Scale (`single_value`) | yes |
**Follow-on (PR 6b)** — same pathway bindings, not blocking MVP:
| Template code | Instruments |
|---------------|-------------|
| `barthel` | Barthel Index |
| `gcs` | Glasgow Coma Scale (E/V/M as score_items + sum or custom) |
| `stroke_swallow` | Swallow assessment |
| `stroke_clinical` | **Stroke subtype** (TIA / ischaemic / haemorrhagic / unspecified), TLKW, CT findings, thrombolysis, thrombectomy, limb strength, aphasia, dysphagia, spasticity, cognition, etc. |
**Subtype (KD-19):** One pathway `stroke` for stroke **and** TIA. Subtype is a `single_choice` (or equivalent) field on `stroke_clinical` (and may be surfaced early on a lightweight field in MVP if needed). Do **not** create a separate TIA pathway.
Match rules: ICD I60I64 (and TIA codes such as G45.* as configured in seed); keywords include stroke, CVA, TIA, cerebrovascular, hemiplegia, ischaemic/ischemic stroke; `exclude_keywords` e.g. family history.
### Diabetes (`pathway.code = diabetes`) — after stroke E2E
Template `diabetes_core` (HbA1c entry optional, foot assessment, monofilament, eye exam, microalbuminuria, neuropathy score, hypoglycaemia episodes, diet adherence, insulin regimen). Optional read-only display of latest lab HbA1c (not hard dependency).
### Deferred packs
Heart Failure, CKD, Hypertension, Asthma, COPD, Dementia, Parkinson's, Cancer, Pregnancy, Orthopaedics — one PR per pack.
---
## Layer 4 — Generic outcome measures
Template `outcome_core`: quality_of_life, pain_score, functional_independence, medication_adherence, hospital_admissions_since_last, falls_count, readmissions_flag, patient_satisfaction. Prefer display of typed vitals for weight/BP rather than re-entry.
Cadence: manual at follow-up; no scheduler in v1.
---
## Alternatives Considered
### Alternative A — Wide disease columns on patients/consultations
**Rejected** — schema churn, sparse nulls, comorbidity nightmare.
### Alternative B — Pure JSON document per consultation
**Rejected** as primary store — weak validation/indexing/audit; JSON only for options, match rules, subscores.
### Alternative C — Full FHIR Questionnaire
**Deferred** — heavy for current monolith; export later if needed.
### Alternative D — Org-scoped template builder UI first
**Deferred** — seed system instruments first; org customization later with explicit uniqueness strategy (see templates uniqueness).
### Alternative E — Consultation `structured_findings` JSON + typed scores table only (no pathway engine)
Lighter intermediate: append parameter-style rows (similar spirit to investigation result values) and a scores table, without templates/pathways.
| Pros | Cons |
|------|------|
| Faster first instrument | No versioned instruments, no comorbidity pathway activation, no reusable content packs, still invents ad hoc keys |
**Rejected** — fails product goals for multi-specialty pathway overlays; full template engine is the target and not much more work once schema exists.
### Chosen approach — Hybrid typed + metadata-driven assessments
Balances hot-path query performance, instrument flexibility, and Care service/UI patterns.
---
## Security & Privacy Considerations
| Threat | Severity | Mitigation |
|--------|----------|------------|
| Cross-tenant assessment read/write | **Critical** | `owner_ref` on runtime; `authorizeOwner` + org check via patient/visit |
| Branch isolation bypass | **High** | KD-13: same `branchScope` as consultations; 404 cross-branch for branch-scoped members |
| Unauthorized specialty documentation | **High** | Route ability + `assertCaptureAllowed` (admin bypass; else `capture_roles`; nurses blocked on disease) |
| Catalog write abuse | **Medium** | No product write API for catalog in v1; seed/deploy only |
| PHI in audit logs | **Medium** | Action + subject ids + codes only; no full answers in metadata |
| Immutable clinical record tampering | **High** | KD-14: complete lock 422; soft-delete scoped queries |
| Template XSS | **Medium** | Seed labels; Blade escape |
| Diagnosis false pathway activation | **Medium** | Suggest-only; clinician confirms (KD-5) |
| Soft-deleted assessment answer leakage | **Low** | Always query answers through non-deleted assessment |
No new public unauthenticated endpoints.
---
## Observability
### v1 (required) — logs + audit only
Ladill Care has **no** StatsD/Prometheus metrics backend today. Do not block implementation on counters.
- **Audit:** start/save/complete/cancel/activate/deactivate via `AuditLogger` + `config/care.php` actions.
- **Structured logs:** `Log::warning` / `Log::error` on scoring failures and capture denials with fields: `template_code`, `assessment_uuid`, `owner_ref` (or org id), `reason`.
- **Optional reporting:** later extend `ReportService` with assessment completion counts from audit or scores table (PR 8 optional).
### Future (not v1)
| Metric | Purpose |
|--------|---------|
| `care.assessments.completed` | Adoption |
| `care.pathways.activated` | Pathway usage |
| `care.scoring.failures` | Content pack bugs |
Alerting: no dedicated channel in v1; scoring failures surface in application logs for on-call when log aggregation exists.
---
## Rollout Plan
### Feature flags (`CareFeatures`, not PlanService)
`PlanService::hasFeature` is for **plan entitlements** (lab, pharmacy, billing). Rollout flags are separate.
**Storage:** `care_organizations.settings` JSON:
```json
{
"plan": "pro",
"rollout": {
"assessments_engine": true,
"pathway_suggestions": true,
"assessment_required_on_complete": false
}
}
```
**Helper:** `App\Services\Care\CareFeatures::enabled(Organization $org, string $flag): bool` reading `settings.rollout.{flag}`, default **false** until explicitly enabled (internal tenants first).
**Ships in PR 2** so UI can gate. Do not nest under `settings.features` (collides conceptually with plan feature lists in `config/care.php`).
### Plan packaging (KD-18 — final)
| Capability | Plan gate |
|------------|-----------|
| Core assessment engine, universal intake, pathway activation, disease pack **capture** (Stroke, Diabetes, …) | **Free** (all plans) — not Enterprise-gated |
| Patient-level outcome history / simple per-patient score list (basic chart) | **Free** |
| **Advanced analytics** — org-level instrument trends, multi-patient assessment dashboards, advanced outcome analytics | **Enterprise** (`PlanService::hasFeature` / enterprise plan features; distinct from rollout flags) |
Rollout flags (`CareFeatures`) control gradual enablement; plan packaging controls Enterprise-only analytics surfaces. Disease content packs are **not** sold as Enterprise-only for capture.
### Stages
1. **Internal** — migrate + seed universal; `rollout.assessments_engine` for staging.
2. **Pilot** — pathways + stroke MVP (NIHSS/mRS).
3. **GA** — default rollout on for new orgs; content pack runbook.
4. **Outcomes / analytics** — basic free; advanced org trends Enterprise-gated.
### Rollback
- Disable rollout flag → hide UI; data retained.
- Reverse migration only if zero production assessments.
- Content pack errors: `is_active = false` on template/pathway.
### Performance safeguards
- Eager-load questions with template.
- Paginate assessment history.
- Use scores table for trends; no answer-table scans for lists.
---
## Risks
| Risk | Severity | Mitigation |
|------|----------|------------|
| Free-text diagnoses noisy matches | Medium | Ranked algorithm + exclude_keywords + explicit picker + match reasons; tests for false positives |
| Clinician form fatigue | Medium | Required templates only acute MVP (NIHSS/mRS); more instruments optional later |
| Scoring bugs | High | Golden tests per content pack; frozen template versions |
| Dual HPI narrative | Medium | KD-15 explicit dual display; no auto-copy |
| Instrument licensing | Medium | `meta.attribution`; legal review before GA per pack |
| Dual BP/weight sources | Low | Typed vitals preferred in UI |
| Race on pathway activate | Medium | Transaction + lockForUpdate; idempotent activate |
| Large stroke content PR | Medium | Split MVP (PR 6) vs 6b |
---
## Resolved decisions
Product decisions closed for implementation (do not re-litigate without a new design revision):
| Topic | Decision | Anchored in |
|-------|----------|-------------|
| **Plan gating** | Engine + universal intake + disease pack **capture** free on all plans. **Advanced analytics / org-level trends** → Enterprise. | KD-18, Rollout → Plan packaging |
| **TIA vs Stroke** | **One** `stroke` pathway. TIA / ischaemic / haemorrhagic are **subtype** fields on stroke assessments (e.g. `stroke_clinical`), not separate pathways. Match rules include TIA. | KD-19, Layer 3 Stroke |
| **Chronic conditions sync** | **No** auto-add. `care_patient_pathways` and `care_patient_conditions` remain independent lists. | KD-20 |
| **Multi-branch chart** | Match consultations / `branchScope`. | KD-13 |
| **Complete immutability (v1)** | Hard lock; no amend in v1. | KD-14 |
| **Dual HPI narrative** | Free-text symptoms primary; universal intake optional overlay; no auto-copy. | KD-15 |
| **Admin capture** | `hospital_admin` / `super_admin` always pass `assertCaptureAllowed`. | KD-9 |
---
## Open Questions
Still open (not blocking PR 1 engine work):
1. **Amend completed assessments (post-v1)** — versioned amendments with reason vs permanent lock after v1?
2. **Lab value pull-through** — how tightly to couple diabetes HbA1c display to `care_investigation_results` in the diabetes pack?
3. **Nurse NIHSS** — org-configurable `capture_roles` later? (v1: disease `["doctor"]`; hospital_admin/super_admin always allowed.)
4. **Internationalization** — English-only first packs?
5. **Clinical instrument licensing** — commercial redistribution rights for NIHSS/mRS/etc. before GA of each pack?
---
## References
### Internal code
| Path | Relevance |
|------|-----------|
| `database/migrations/2026_07_01_100000_create_care_patient_tables.php` | Patient, allergies, conditions, family, insurance |
| `database/migrations/2026_07_02_100000_create_care_clinical_tables.php` | Visits, consultations, vitals, diagnoses |
| `database/migrations/2026_07_03_100000_create_care_lab_and_prescription_tables.php` | Result values: **string** `value` only |
| `app/Models/Consultation.php` | Status draft/completed, relations |
| `app/Models/Diagnosis.php` | Pathway matching input |
| `app/Models/VitalSign.php` | Typed vitals retained |
| `app/Models/Concerns/BelongsToOwner.php` | Runtime tenant scope |
| `app/Services/Care/ConsultationService.php` | save/complete/syncDiagnoses |
| `app/Services/Care/PatientService.php` | Registration remains universal |
| `app/Services/Care/CarePermissions.php` | Role abilities (extend) |
| `app/Services/Care/PlanService.php` | Plan entitlements — **not** rollout flags |
| `app/Services/Care/AuditLogger.php` | Clinical audit |
| `app/Http/Controllers/Care/ConsultationController.php` | Web integration; vitals vs manage split pattern |
| `app/Http/Controllers/Care/Concerns/ScopesToAccount.php` | Authz helpers |
| `resources/views/care/consultations/show.blade.php` | Primary UI surface |
| `resources/views/care/patients/_form.blade.php` | Demographics (no specialty) |
| `config/care.php` | Enums, audit actions, plans |
| `routes/web.php` | Route registration |
| `tests/Feature/CarePatientTest.php`, `CareLabTest.php` | Test style to extend |
| `database/seeders/` | Currently empty of assessment packs — greenfield |
### External clinical instruments
- NIH Stroke Scale (NIHSS), Modified Rankin Scale (mRS), Barthel, GCS, NYHA, CAT, mMRC, Hoehn & Yahr, UPDRS, MMSE/MoCA — with licensing review before commercial packaging.
---
## PR Plan
Incremental, independently reviewable PRs. Merge order as listed.
### Milestones
| Milestone | After PRs | Demoable outcome |
|-----------|-----------|------------------|
| **M1 — Universal intake** | PR 13 | Register patient; complete structured universal intake on consultation; free-text symptoms still work |
| **M2 — Pathways + stroke E2E** | PR 46 | Save diagnoses → suggestions → activate stroke → complete NIHSS/mRS with scores |
| **M3 — Breadth** | PR 6b8 | Extra stroke instruments, diabetes, outcomes trends |
| **M4 — API** | PR 9 | Mobile/API parity |
### PR 1 — Assessment engine schema + domain models
- **Title:** `feat(assessments): add template-driven assessment tables and models`
- **Depends on:** none
- **Files:** migration for templates, questions, assessments, answers (**no** scores table); catalog models without `BelongsToOwner`; runtime models with `BelongsToOwner`; minimal relation/uuid tests
- **Description:** Additive schema only. Establishes catalog tenancy contract and uniqueness `(code, version)` for system templates.
### PR 2 — AssessmentService + permissions + CareFeatures + web CRUD
- **Title:** `feat(assessments): service layer, permissions, rollout flags, and capture UI`
- **Depends on:** PR 1
- **Files:**
- `AssessmentService` (normalize invariants, capture_roles, draft idempotency, cancel, complete lock)
- `CareFeatures` + `settings.rollout.*`
- `CarePermissions`: `assessments.view`, `assessments.capture`, `assessments.manage`
- `AssessmentController`, routes, Blade forms
- `config/care.php` statuses + audit actions
- Feature tests: nurse universal yes / NIHSS no; doctor + hospital_admin NIHSS yes; tenant 404; public UUID body fields
- **Description:** Full capture lifecycle gated by rollout flag. No pathway tables yet.
### PR 3 — Universal intake seed + consultation entry
- **Title:** `feat(assessments): seed universal intake and link from consultation`
- **Depends on:** PR 2
- **Files:** `database/data/assessments/universal_intake.v1.json`, `AssessmentTemplateSeeder`, wire `DatabaseSeeder` / deploy note; consultation show section; patient timeline; dual narrative UI (symptoms + intake); tests
- **Description:** M1 complete. Layer 1 structured overlay without patient column churn.
### PR 4 — Scoring materialization
- **Title:** `feat(assessments): scoring service and care_assessment_scores`
- **Depends on:** PR 2
- **Files:** **only** migration for `care_assessment_scores` (not in PR 1); `AssessmentScore`; `ScoringService` + `sum_items` / `single_value`; unit tests; complete path integration
- **Description:** Materialize scores on complete. Required before specialty packs.
### PR 5 — Clinical pathway registry + patient activation
- **Title:** `feat(pathways): clinical pathway registry and patient activation`
- **Depends on:** PR 2 (PR 3 recommended for realistic consultation UX)
- **Files:** pathway migrations (`template_code` only on bindings); `PathwayService`, `PathwayMatcher` (full algorithm + tests: I63.9, ischaemic stroke, history unclear, exclude family history); `pathways.manage`; deactivate POST (not DELETE); suggestions endpoint on **persisted** diagnoses; consultation UI suggestions + “Save diagnoses & suggest”; activate creates required drafts; `lockForUpdate` uniqueness; `CarePathwayTest`
- **Description:** Layer 2. Explicit activation; comorbidity-safe.
### PR 6 — Stroke MVP content pack (NIHSS + mRS)
- **Title:** `feat(pathways): stroke MVP content pack (NIHSS, mRS)`
- **Depends on:** PR 4, PR 5 (PR 3 recommended)
- **Files:** `nihss.v1.json`, `mrs.v1.json`, stroke pathway JSON with required bindings; golden scoring tests; consultation instruments list when stroke active
- **Description:** **M2 vertical slice.** Do not include Barthel/GCS/swallow/clinical in this PR.
### PR 6b — Stroke extended instruments
- **Title:** `feat(pathways): stroke extended instruments (Barthel, GCS, swallow, clinical)`
- **Depends on:** PR 6
- **Files:** remaining stroke templates + pathway bindings (optional/required as product chooses); scoring tests
- **Description:** Completes full stroke module without blocking E2E milestone.
### PR 7 — Diabetes content pack
- **Title:** `feat(pathways): diabetes assessment content pack`
- **Depends on:** PR 6 (stroke E2E proven), PR 4, PR 5
- **Files:** `diabetes_core` seed + pathway; optional lab HbA1c display; comorbidity tests (stroke + diabetes)
- **Description:** Second module after stroke path is stable.
### PR 8 — Generic outcomes + patient trends (+ optional ReportService)
- **Title:** `feat(assessments): outcome measures template and patient trend view`
- **Depends on:** PR 4, PR 2
- **Files:** `outcome_core` seed; per-patient outcome history (free); optional org-level `ReportService` analytics gated Enterprise (KD-18)
- **Description:** Layer 4. Basic patient chart free; advanced multi-patient trends Enterprise.
### PR 9 — API parity + OpenAPI
- **Title:** `feat(api): assessment and pathway endpoints`
- **Depends on:** PR 5, PR 2
- **Files:** API controllers; `routes/api.php`; `docs/openapi/care.yaml`; feature tests paralleling `CareLabTest` tenant/branch isolation
- **Description:** Mobile/clients parity.
### PR 10 — Additional content packs (series)
- **Title:** `feat(pathways): {copd|heart_failure|dementia|…} content pack`
- **Depends on:** PR 5, PR 4
- **Description:** One PR per pack; schema-stable.
### Out of scope for this PR sequence
- Org template builder / org forks
- FHIR Questionnaire export
- Hard-block consultation complete on missing assessments
- Automated outcome reminders
- Full ICD-10 terminology service
- Metrics backend integration
- Org match_rules overrides
---
## Success criteria
1. New patient can be registered and consulted **without** selecting a specialty.
2. Clinician can complete a **universal intake** assessment linked to a consultation (M1).
3. After **saving** diagnoses, **pathway suggestions** appear with match reasons; activation supports **multiple** pathways (M2).
4. Stroke pathway exposes **NIHSS + mRS**; completion stores answers + **materialized score** (M2).
5. Adding a new instrument requires **seed JSON + seeder only**, no clinical column migration.
6. Assessment mutations appear in **audit log**; tenant + branch isolation enforced in tests.
7. Typed vitals/diagnoses/labs and free-text symptoms continue to work unchanged on `consultations/show`.
8. Nurse can capture universal intake; nurse **cannot** start NIHSS; doctor and **hospital_admin** can.
9. Completed assessment PUT returns **422**; cancel works on drafts only.
10. Web/API request bodies reference consultations/visits by **UUID**, resolved server-side to FKs.