# 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
PathwayController]
Svc[AssessmentService
PathwayService
ScoringService]
Feat[CareFeatures rollout flags]
Match[PathwayMatcher]
Models[(Catalog global
Runtime tenant-scoped)]
Exist[ConsultationService
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 / 0–10 |
| `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 question’s `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 item’s `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 $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 $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,000–50,000 |
| Assessments per patient-year | 4–20 |
| Questions per instrument | 5–20 (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 (0–6).",
"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 0–10), `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 I60–I64 (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 1–3 | Register patient; complete structured universal intake on consultation; free-text symptoms still work |
| **M2 — Pathways + stroke E2E** | PR 4–6 | Save diagnoses → suggestions → activate stroke → complete NIHSS/mRS with scores |
| **M3 — Breadth** | PR 6b–8 | 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.