# 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.