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

72 KiB
Raw Blame History

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.phpaudit_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 gapcare_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 patternscare_ 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. 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.
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).
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.
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

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)

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)

flowchart LR
  UI[Blade consultation / patient show]
  API[API AssessmentController]
  Ctl[Care\\AssessmentController<br/>PathwayController]
  Svc[AssessmentService<br/>PathwayService<br/>ScoringService]
  Feat[CareFeatures rollout flags]
  Match[PathwayMatcher]
  Models[(Catalog global<br/>Runtime tenant-scoped)]
  Exist[ConsultationService<br/>Patient / Diagnosis / VitalSign]

  UI --> Ctl
  UI --> Feat
  API --> Svc
  Ctl --> Svc
  Ctl --> Feat
  Svc --> Models
  Svc --> Match
  Exist --> Match
  Svc --> Exist

Place services under app/Services/Care/ alongside ConsultationService, InvestigationService. Controllers under app/Http/Controllers/Care/ and Api/. Follow ScopesToAccount + authorizeAbility patterns from ConsultationController.

Domain concepts

Concept Definition
Template Versioned form definition (e.g. universal_intake, nihss, mrs, outcome_core).
Question Field within a template: code, label, answer type, options, validation, scoring weight, section.
Assessment Completed or draft instance of a template for a patient at a point in time.
Answer Value for one question on one assessment (single typed column authoritative).
Pathway Clinical module registry entry (Stroke, Diabetes, …) binding diagnosis match rules to templates by code.
Patient pathway Active association of a pathway to a patient (onset, status, activating diagnosis text snapshot).
Score Materialized instrument total/subscores for an assessment.

Catalog tenancy contract

This engine intentionally diverges from care_investigation_types (which is org-scoped with owner_ref + organization_id).

Layer Tables Tenancy Authz
Catalog care_assessment_templates, care_assessment_questions, care_clinical_pathways, care_pathway_templates No owner_ref. Platform-global rows only in v1. organization_id is null and reserved for a future org-fork phase (not used in v1 writes). Read: any authenticated Care member with assessments.view (or consultation context). Write: not via product UI in v1 — only seeders/migrations/deploy. No authorizeOwner on catalog models.
Runtime care_assessments, care_assessment_answers, care_assessment_scores, care_patient_pathways Always owner_ref + organization_id copied from patient (and branch_id per KD-13). Models use BelongsToOwner. authorizeOwner + org check + branchScope like consultations.

Models / traits:

  • Catalog models: do not use BelongsToOwner.
  • Runtime models: do use BelongsToOwner + soft deletes where specified.

Canonical catalog queries:

// Current system template by code (v1: all catalog rows are system)
AssessmentTemplate::query()
    ->whereNull('organization_id')
    ->where('code', $code)
    ->where('is_current', true)
    ->where('is_active', true)
    ->whereNull('deleted_at')
    ->firstOrFail();

// Active pathways for matching
ClinicalPathway::query()
    ->where('is_active', true)
    ->whereNull('deleted_at')
    ->orderBy('sort_order')
    ->get();

Seeding across tenants: one shared catalog in the app database; all owner_ref tenants read the same NIHSS definition. White-label multi-DB deploys each seed their own catalog (same JSON packs). There is no per-tenant catalog copy in v1.

Why not owner_ref on catalog: system NIHSS is not owned by any hospital account; putting a seeder owner_ref would break authorizeOwner for other tenants or force awkward sentinel values.

Template categories

category Purpose Default meta.capture_roles
universal Layer 1 structured intake ["doctor","nurse"]
disease Layer 3 instruments ["doctor"]
outcome Layer 4 longitudinal ["doctor","nurse"]
screening Optional later ["doctor","nurse"]

Answer types and write/read invariants

Supported answer_type values. Exactly one storage column is authoritative; on write, all other value columns must be set to null.

Type Authoritative column Payload shape UI control
text value_text string input
textarea value_text string textarea
number value_number numeric number input
integer value_number integer (stored as decimal) number input
boolean value_boolean bool checkbox
date value_date Y-m-d date
datetime value_date ISO datetime datetime
single_choice value_text option code string radio/select
multi_choice value_json array of option codes checkboxes
scale value_number number within min/max Likert / 010
score_item value_number numeric score (from choice or direct) scored choice (NIHSS item)
calculated value_number not client-writable in v1 read-only display

Normalization (AssessmentService::normalizeValue):

  1. Validate raw payload against type + validation / options.
  2. Build row with only the authoritative column set; explicitly null the others.
  3. For score_item + single_choice-style choices: client may send option code; server maps options.choices[].scorevalue_number, and may also store code in value_text only if type were single_choice. For pure score_item, store score in value_number only (code optional in value_json as {"code":"1"} if audit of selection needed — v1: store score only in value_number).
  4. For multi_choice: scores are not auto-summed unless scoring_strategy handles them (v1 disease instruments use score_item, not multi_choice, for scored totals).
  5. calculated fields (v1 deferred for formulas): if present, ignore client input; recompute only if options.formula is absent — v1 ships no calculated questions. Reserve type for PR later; seed packs must not use calculated until formula support lands.

Read path: UI and scoring always read the authoritative column for the questions answer_type; never fall back to another column.

Options JSON on questions:

{
  "choices": [
    {"code": "0", "label": "No symptoms at all", "score": 0},
    {"code": "1", "label": "No significant disability", "score": 1}
  ],
  "min": 0,
  "max": 6,
  "unit": null
}

Scoring contract

ScoringService runs on complete (and optionally preview on draft save for live totals — preview does not persist).

scoring_strategy Behavior
null / empty No score row required (e.g. free-text heavy forms). Complete still validates required questions.
sum_items Sum value_number for all questions with answer_type = score_item (and optional score_key buckets into subscores). max_score from sum of each items options.max or max choice score.
single_value Exactly one scored question (usually score_item or scale); total_score = that value_number. Used for mRS.
custom:{Handler} Map to App\Services\Care\Scoring\{Handler} implementing ScoresAssessment interface (score(Assessment): array{total, max, subscores, severity_label}). Register via strategy string only — no dynamic class from user input.

Complete-time validation:

  1. All is_required questions have non-null authoritative values.
  2. If strategy is sum_items or single_value, all score_item questions required for the instrument must be present (treat missing as 422).
  3. On success: upsert care_assessment_scores; audit assessment.completed.
  4. Idempotent complete: if already completed, return 422 (no re-complete) per KD-14.
  5. Scoring failure: Log::warning with template_code, assessment_uuid, exception message; do not complete; return 422.

Pathway matching (detailed)

PathwayMatcher input: persisted diagnosis rows only (code, description) — see Pathway suggestion UX.

Rules on care_clinical_pathways.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"]
}

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::savesyncDiagnoses (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):

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

// 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_codeis_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 .../cancelstatus=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)

{
  "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}

{
  "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

{
  "data": [
    {
      "pathway_code": "stroke",
      "pathway_name": "Stroke",
      "rank": 100,
      "match_reason": "icd_prefix:I63",
      "already_active": false
    }
  ]
}

POST /patients/{patient}/pathways

{
  "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

// app/Services/Care/AssessmentService.php
class AssessmentService
{
    public function start(
        Patient $patient,
        string $templateCode, // resolves is_current system template
        string $ownerRef,
        ?Member $member,
        array $context = [], // consultation?, visit?, patient_pathway? (models or internal ids after controller resolve), actor
    ): Assessment;

    /** @param array<string, mixed> $answers keyed by question code */
    public function saveAnswers(
        Assessment $assessment,
        string $ownerRef,
        array $answers,
        ?Member $member = null,
        ?string $actorRef = null,
    ): Assessment;

    public function complete(Assessment $assessment, string $ownerRef, ?Member $member = null, ?string $actorRef = null): Assessment;

    public function cancel(Assessment $assessment, string $ownerRef, ?Member $member = null, ?string $actorRef = null): Assessment;

    public function listForPatient(Patient $patient, string $ownerRef, array $filters = [], ?int $branchScope = null): LengthAwarePaginator;
}

// app/Services/Care/PathwayService.php
class PathwayService
{
    /** @param  iterable<Diagnosis|array{code?:string,description?:string}>  $diagnoses */
    public function suggest(iterable $diagnoses): Collection; // unique pathways + rank + reason

    public function activate(
        Patient $patient,
        ClinicalPathway $pathway,
        string $ownerRef,
        array $context = [], // consultation? (model after UUID resolve), activation_diagnosis_text, actor
    ): PatientPathway;

    public function deactivate(PatientPathway $patientPathway, string $ownerRef, ?string $actorRef = null): PatientPathway;

    public function activeFor(Patient $patient): Collection;
}

// app/Services/Care/ScoringService.php
class ScoringService
{
    public function materialize(Assessment $assessment): AssessmentScore;
    /** @return array{total: ?float, max: ?float, subscores: array, severity_label: ?string} */
    public function preview(Assessment $assessment): array;
}

// app/Services/Care/CareFeatures.php  (rollout flags — NOT PlanService)
class CareFeatures
{
    public function enabled(Organization $organization, string $flag): bool;
    // keys under settings.rollout.*
}

Permissions (CarePermissions)

Ability Roles (v1) Purpose
assessments.view doctor, nurse, hospital_admin, super_admin Read assessments/pathways
assessments.capture doctor, nurse, hospital_admin, super_admin Start/save/complete/cancel (subject to assertCaptureAllowed)
assessments.manage doctor, hospital_admin, super_admin Route-level capture access (same start/save/complete endpoints as capture) plus future admin ops; does not alone bypass template rules — service still runs assertCaptureAllowed
pathways.manage doctor, hospital_admin, super_admin Activate/deactivate pathways

Why admin bypass matters: OrganizationResolver::ensureOwnerMember creates the facility owner as hospital_admin, not doctor. Small clinics often have a single clinician-owner on that role. Disease templates seed capture_roles: ["doctor"] for clinical scope — without an explicit admin exception, owners could activate stroke pathways but get 403 on NIHSS/mRS.

Enforcement layers (both required):

  1. Route/controller: member must have assessments.capture or assessments.manage (or assessments.view for reads; pathways.manage for pathway mutations).
  2. Service — single rule for all start/save/complete/cancel:
assertCaptureAllowed(Member $member, AssessmentTemplate $template):
  if member is null → 403
  if member.role in {hospital_admin, super_admin} → allow
  roles = template.meta.capture_roles
        ?? default_capture_roles(template.category)
        // universal/outcome/screening → ["doctor","nurse"]
        // disease → ["doctor"]
  if member.role ∈ roles → allow
  else → 403

Notes:

  • hospital_admin / super_admin always pass capture checks (owner/admin clinicians). They still need a route ability (capture or manage); both roles have * in CarePermissions today, so that is satisfied.
  • assessments.manage does not invent a second capture matrix — it only unlocks the same endpoints as capture for roles that hold manage (doctor already holds both). Template scope is only decided by assertCaptureAllowed above.
  • Disease seeds keep capture_roles: ["doctor"]; nurses remain 403 on NIHSS; doctors 201; hospital_admin 201 via admin branch (not by expanding seed lists to every admin role).

Feature tests required:

Actor role Template Expected
nurse universal_intake 201
nurse nihss 403
doctor nihss 201
hospital_admin nihss 201
super_admin nihss 201

Audit actions (add to config/care.php)

assessment.started
assessment.updated
assessment.completed
assessment.cancelled
pathway.activated
pathway.deactivated

Metadata: subject ids, template_code, pathway_codenot 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

erDiagram
  care_patients ||--o{ care_assessments : has
  care_consultations ||--o{ care_assessments : context
  care_visits ||--o{ care_assessments : context
  care_assessment_templates ||--o{ care_assessment_questions : defines
  care_assessment_templates ||--o{ care_assessments : instances
  care_assessments ||--o{ care_assessment_answers : has
  care_assessment_questions ||--o{ care_assessment_answers : answers
  care_assessments ||--o| care_assessment_scores : score
  care_clinical_pathways ||--o{ care_pathway_templates : includes
  care_patients ||--o{ care_patient_pathways : enrolled
  care_clinical_pathways ||--o{ care_patient_pathways : activated

Note: no FK from care_pathway_templates to care_assessment_templates — linkage by template_code string (KD-16).

New tables

Catalog tenancy (summary)

  • Catalog: no owner_ref; platform-global; v1 no org clones.
  • Runtime: always owner_ref + org (+ branch on assessments).

care_assessment_templates

Column Type Notes
id bigint PK
uuid uuid unique public id
organization_id FK nullable v1 always NULL (system). Reserved for future forks.
code string e.g. nihss, universal_intake
name string
category string universal / disease / outcome / screening
description text nullable
version unsigned int default 1
is_current boolean
scoring_strategy string nullable sum_items, single_value, custom:HandlerName
meta json nullable capture_roles, estimated_minutes, specialty, attribution
is_active boolean
timestamps
softDeletes

Uniqueness (v1):

  • Application invariant: for system rows (organization_id IS NULL), unique (code, version).
  • DB: unique index on (code, version) valid in v1 because only system rows exist and org clones are out of scope.
  • Future org forks (not v1): use separate namespace — e.g. code prefix org_{id}_nihss or unique (organization_id, code, version) with sentinel organization_id = 0 for system if MySQL NULL uniqueness is a problem. Documented so implementers do not invent conflicting clones mid-flight.
  • Also enforce at most one row with (code, is_current=true, organization_id null) in service/seeder.

care_assessment_questions

Column Type Notes
id bigint PK
template_id FK cascade
code string stable within template version
section string nullable
label string
help_text text nullable
answer_type string see answer types
options json nullable choices, min/max, unit
is_required boolean
sort_order unsigned int
score_key string nullable subscore bucket
validation json nullable min, max, regex
timestamps
unique (template_id, code)

care_assessments

Column Type Notes
id bigint PK
uuid uuid unique route key
owner_ref string indexed from patient
organization_id FK denormalized
branch_id FK nullable visit preferred else patient.branch_id
patient_id FK cascade
template_id FK restrict frozen version
consultation_id FK nullOnDelete
visit_id FK nullOnDelete
patient_pathway_id FK nullOnDelete
practitioner_id FK nullable
status string draft, completed, cancelled
assessed_at timestamp nullable clinical time (default now on complete)
completed_at timestamp nullable
completed_by string nullable
started_by string nullable
notes text nullable
timestamps
softDeletes
indexes (patient_id, assessed_at), (consultation_id), (owner_ref, status), (template_id, status), (patient_id, template_id, status)

care_assessment_answers

Column Type Notes
id bigint PK
owner_ref string indexed
assessment_id FK cascade
question_id FK restrict
value_text text nullable
value_number decimal(12,4) nullable
value_boolean boolean nullable
value_date datetime nullable
value_json json nullable
timestamps
unique (assessment_id, question_id)

No soft deletes on answers.

care_assessment_scores (PR 4 migration)

Column Type Notes
id bigint PK
owner_ref string indexed
assessment_id FK cascade unique
template_code string indexed
total_score decimal(12,4) nullable
max_score decimal(12,4) nullable
subscores json nullable
severity_label string nullable
computed_at timestamp
timestamps
index (owner_ref, template_code, computed_at)

care_clinical_pathways

Column Type Notes
id bigint PK
uuid uuid unique
code string unique stroke, diabetes, …
name string
description text nullable
match_rules json icd_prefixes, keywords, exclude_keywords
is_active boolean
sort_order unsigned int
meta json nullable icon, color
timestamps
softDeletes

No owner_ref (catalog).

care_pathway_templates

Column Type Notes
id bigint PK
pathway_id FK cascade
template_code string only — resolve is_current at start (KD-16)
is_required_on_activation boolean create draft when pathway activated
phase string acute, follow_up, any
sort_order unsigned int
unique (pathway_id, template_code)

No template_id column.

care_patient_pathways

Column Type Notes
id bigint PK
uuid uuid unique
owner_ref string indexed
organization_id FK
patient_id FK cascade
pathway_id FK restrict
status string active, resolved, inactive
activated_at timestamp
activated_by string nullable
activation_consultation_id FK nullable
activation_diagnosis_text string nullable snapshot — not diagnosis FK
resolved_at timestamp nullable
notes text nullable
timestamps
softDeletes
index (patient_id, status), (patient_id, pathway_id, status)

What stays typed (no change)

  • care_patients, emergency contacts, insurance, allergies, family history, conditions
  • care_vital_signs
  • care_diagnoses
  • investigation catalog/results (string value on result values unchanged)
  • prescriptions, bills

Migration strategy

  1. PR 1: templates, questions, assessments, answers (no scores).
  2. PR 4: care_assessment_scores + scoring service.
  3. PR 5: pathway tables.
  4. Seed via database/seeders/* + JSON packs; wire into DatabaseSeeder / deploy runbook.
  5. No backfill of historical consultations.
  6. Rollback: feature flag off; drop tables only if zero production runtime rows.

Storage / load estimates

Assumption Value
Active patients per org 5,00050,000
Assessments per patient-year 420
Questions per instrument 520 (NIHSS ~15)
Answer rows per assessment ≈ question count
Growth ~1M answer rows / large org / year

Seed/JSON content pack schema

Path convention: database/data/assessments/{code}.v{version}.json

Seeder: AssessmentTemplateSeeder upserts by (code, version); sets is_current per pack; loads questions replacing by template_id. ClinicalPathwaySeeder loads pathways + pathway_templates bindings.

Root object:

{
  "template": {
    "code": "mrs",
    "name": "Modified Rankin Scale",
    "category": "disease",
    "version": 1,
    "is_current": true,
    "is_active": true,
    "scoring_strategy": "single_value",
    "description": "Global disability scale after stroke (06).",
    "meta": {
      "capture_roles": ["doctor"],
      "specialty": "neurology",
      "estimated_minutes": 2,
      "attribution": "Modified Rankin Scale — use under applicable clinical/educational license; verify commercial redistribution rights before packaging."
    }
  },
  "questions": [
    {
      "code": "mrs_score",
      "section": "scale",
      "label": "Modified Rankin Scale score",
      "help_text": "Select the grade that best describes the patient.",
      "answer_type": "score_item",
      "is_required": true,
      "sort_order": 1,
      "score_key": "total",
      "options": {
        "min": 0,
        "max": 6,
        "choices": [
          {"code": "0", "label": "No symptoms at all", "score": 0},
          {"code": "1", "label": "No significant disability despite symptoms", "score": 1},
          {"code": "2", "label": "Slight disability", "score": 2},
          {"code": "3", "label": "Moderate disability", "score": 3},
          {"code": "4", "label": "Moderately severe disability", "score": 4},
          {"code": "5", "label": "Severe disability", "score": 5},
          {"code": "6", "label": "Dead", "score": 6}
        ]
      },
      "validation": {"min": 0, "max": 6}
    }
  ]
}

Pathway pack (database/data/pathways/{code}.json):

{
  "pathway": {
    "code": "stroke",
    "name": "Stroke",
    "match_rules": {
      "icd_prefixes": ["I60", "I61", "I62", "I63", "I64", "G45"],
      "keywords": ["stroke", "cva", "tia", "transient ischaemic attack", "transient ischemic attack", "cerebrovascular", "hemiplegia", "ischaemic stroke", "ischemic stroke"],
      "exclude_keywords": ["family history"]
    },
    "is_active": true,
    "sort_order": 10
  },
  "templates": [
    {"template_code": "nihss", "is_required_on_activation": true, "phase": "acute", "sort_order": 1},
    {"template_code": "mrs", "is_required_on_activation": true, "phase": "acute", "sort_order": 2}
  ]
}

TIA diagnoses match this same stroke pathway (KD-19); subtype is recorded on assessment, not via a second pathway code.

Content-pack PR requirements:

  1. JSON conforming to schema above.
  2. Golden unit tests for scoring (e.g. mRS 3 → total 3; NIHSS fixture answers → known total).
  3. Attribution/licensing note in meta.attribution (Open Question: commercial use of instrument IP — legal review before GA of each pack).
  4. Seeder registration in DatabaseSeeder or documented php artisan db:seed --class=... in deploy runbook.

Universal template content (Layer 1 structured)

Seed template code = universal_intake (illustrative sections):

Presenting complaint: chief_complaint (textarea, required), hpi, duration_value + duration_unit, pain_score (scale 010), current_symptoms.

Social history: smoking_status, smoking_pack_years, alcohol_use, occupation.

Functional status: mobility, communication, vision, hearing, feeding, continence.

Baseline labs: optional baseline_labs_summary only; prefer investigation results.

Demographics/allergies/vitals remain on existing typed UI.

Dual narrative (KD-15): consultation symptoms / clinical_notes remain the narrative source of truth for free-text clinical story. Universal intake does not replace or auto-sync them. Chart displays both when present.


Disease module content packs (Layer 3)

Stroke (pathway.code = stroke)

MVP (PR 6) — production vertical slice:

Template code Instruments Required on activation
nihss NIH Stroke Scale items + total (sum_items) yes
mrs Modified Rankin Scale (single_value) yes

Follow-on (PR 6b) — same pathway bindings, not blocking MVP:

Template code Instruments
barthel Barthel Index
gcs Glasgow Coma Scale (E/V/M as score_items + sum or custom)
stroke_swallow Swallow assessment
stroke_clinical Stroke subtype (TIA / ischaemic / haemorrhagic / unspecified), TLKW, CT findings, thrombolysis, thrombectomy, limb strength, aphasia, dysphagia, spasticity, cognition, etc.

Subtype (KD-19): One pathway stroke for stroke and TIA. Subtype is a single_choice (or equivalent) field on stroke_clinical (and may be surfaced early on a lightweight field in MVP if needed). Do not create a separate TIA pathway.

Match rules: ICD I60I64 (and TIA codes such as G45.* as configured in seed); keywords include stroke, CVA, TIA, cerebrovascular, hemiplegia, ischaemic/ischemic stroke; exclude_keywords e.g. family history.

Diabetes (pathway.code = diabetes) — after stroke E2E

Template diabetes_core (HbA1c entry optional, foot assessment, monofilament, eye exam, microalbuminuria, neuropathy score, hypoglycaemia episodes, diet adherence, insulin regimen). Optional read-only display of latest lab HbA1c (not hard dependency).

Deferred packs

Heart Failure, CKD, Hypertension, Asthma, COPD, Dementia, Parkinson's, Cancer, Pregnancy, Orthopaedics — one PR per pack.


Layer 4 — Generic outcome measures

Template outcome_core: quality_of_life, pain_score, functional_independence, medication_adherence, hospital_admissions_since_last, falls_count, readmissions_flag, patient_satisfaction. Prefer display of typed vitals for weight/BP rather than re-entry.

Cadence: manual at follow-up; no scheduler in v1.


Alternatives Considered

Alternative A — Wide disease columns on patients/consultations

Rejected — schema churn, sparse nulls, comorbidity nightmare.

Alternative B — Pure JSON document per consultation

Rejected as primary store — weak validation/indexing/audit; JSON only for options, match rules, subscores.

Alternative C — Full FHIR Questionnaire

Deferred — heavy for current monolith; export later if needed.

Alternative D — Org-scoped template builder UI first

Deferred — seed system instruments first; org customization later with explicit uniqueness strategy (see templates uniqueness).

Alternative E — Consultation structured_findings JSON + typed scores table only (no pathway engine)

Lighter intermediate: append parameter-style rows (similar spirit to investigation result values) and a scores table, without templates/pathways.

Pros Cons
Faster first instrument No versioned instruments, no comorbidity pathway activation, no reusable content packs, still invents ad hoc keys

Rejected — fails product goals for multi-specialty pathway overlays; full template engine is the target and not much more work once schema exists.

Chosen approach — Hybrid typed + metadata-driven assessments

Balances hot-path query performance, instrument flexibility, and Care service/UI patterns.


Security & Privacy Considerations

Threat Severity Mitigation
Cross-tenant assessment read/write Critical owner_ref on runtime; authorizeOwner + org check via patient/visit
Branch isolation bypass High KD-13: same branchScope as consultations; 404 cross-branch for branch-scoped members
Unauthorized specialty documentation High Route ability + assertCaptureAllowed (admin bypass; else capture_roles; nurses blocked on disease)
Catalog write abuse Medium No product write API for catalog in v1; seed/deploy only
PHI in audit logs Medium Action + subject ids + codes only; no full answers in metadata
Immutable clinical record tampering High KD-14: complete lock 422; soft-delete scoped queries
Template XSS Medium Seed labels; Blade escape
Diagnosis false pathway activation Medium Suggest-only; clinician confirms (KD-5)
Soft-deleted assessment answer leakage Low Always query answers through non-deleted assessment

No new public unauthenticated endpoints.


Observability

v1 (required) — logs + audit only

Ladill Care has no StatsD/Prometheus metrics backend today. Do not block implementation on counters.

  • Audit: start/save/complete/cancel/activate/deactivate via AuditLogger + config/care.php actions.
  • Structured logs: Log::warning / Log::error on scoring failures and capture denials with fields: template_code, assessment_uuid, owner_ref (or org id), reason.
  • Optional reporting: later extend ReportService with assessment completion counts from audit or scores table (PR 8 optional).

Future (not v1)

Metric Purpose
care.assessments.completed Adoption
care.pathways.activated Pathway usage
care.scoring.failures Content pack bugs

Alerting: no dedicated channel in v1; scoring failures surface in application logs for on-call when log aggregation exists.


Rollout Plan

Feature flags (CareFeatures, not PlanService)

PlanService::hasFeature is for plan entitlements (lab, pharmacy, billing). Rollout flags are separate.

Storage: care_organizations.settings JSON:

{
  "plan": "pro",
  "rollout": {
    "assessments_engine": true,
    "pathway_suggestions": true,
    "assessment_required_on_complete": false
  }
}

Helper: App\Services\Care\CareFeatures::enabled(Organization $org, string $flag): bool reading settings.rollout.{flag}, default false until explicitly enabled (internal tenants first).

Ships in PR 2 so UI can gate. Do not nest under settings.features (collides conceptually with plan feature lists in config/care.php).

Plan packaging (KD-18 — final)

Capability Plan gate
Core assessment engine, universal intake, pathway activation, disease pack capture (Stroke, Diabetes, …) Free (all plans) — not Enterprise-gated
Patient-level outcome history / simple per-patient score list (basic chart) Free
Advanced analytics — org-level instrument trends, multi-patient assessment dashboards, advanced outcome analytics Enterprise (PlanService::hasFeature / enterprise plan features; distinct from rollout flags)

Rollout flags (CareFeatures) control gradual enablement; plan packaging controls Enterprise-only analytics surfaces. Disease content packs are not sold as Enterprise-only for capture.

Stages

  1. Internal — migrate + seed universal; rollout.assessments_engine for staging.
  2. Pilot — pathways + stroke MVP (NIHSS/mRS).
  3. GA — default rollout on for new orgs; content pack runbook.
  4. Outcomes / analytics — basic free; advanced org trends Enterprise-gated.

Rollback

  • Disable rollout flag → hide UI; data retained.
  • Reverse migration only if zero production assessments.
  • Content pack errors: is_active = false on template/pathway.

Performance safeguards

  • Eager-load questions with template.
  • Paginate assessment history.
  • Use scores table for trends; no answer-table scans for lists.

Risks

Risk Severity Mitigation
Free-text diagnoses noisy matches Medium Ranked algorithm + exclude_keywords + explicit picker + match reasons; tests for false positives
Clinician form fatigue Medium Required templates only acute MVP (NIHSS/mRS); more instruments optional later
Scoring bugs High Golden tests per content pack; frozen template versions
Dual HPI narrative Medium KD-15 explicit dual display; no auto-copy
Instrument licensing Medium meta.attribution; legal review before GA per pack
Dual BP/weight sources Low Typed vitals preferred in UI
Race on pathway activate Medium Transaction + lockForUpdate; idempotent activate
Large stroke content PR Medium Split MVP (PR 6) vs 6b

Resolved decisions

Product decisions closed for implementation (do not re-litigate without a new design revision):

Topic Decision Anchored in
Plan gating Engine + universal intake + disease pack capture free on all plans. Advanced analytics / org-level trends → Enterprise. KD-18, Rollout → Plan packaging
TIA vs Stroke One stroke pathway. TIA / ischaemic / haemorrhagic are subtype fields on stroke assessments (e.g. stroke_clinical), not separate pathways. Match rules include TIA. KD-19, Layer 3 Stroke
Chronic conditions sync No auto-add. care_patient_pathways and care_patient_conditions remain independent lists. KD-20
Multi-branch chart Match consultations / branchScope. KD-13
Complete immutability (v1) Hard lock; no amend in v1. KD-14
Dual HPI narrative Free-text symptoms primary; universal intake optional overlay; no auto-copy. KD-15
Admin capture hospital_admin / super_admin always pass assertCaptureAllowed. KD-9

Open Questions

Still open (not blocking PR 1 engine work):

  1. Amend completed assessments (post-v1) — versioned amendments with reason vs permanent lock after v1?
  2. Lab value pull-through — how tightly to couple diabetes HbA1c display to care_investigation_results in the diabetes pack?
  3. Nurse NIHSS — org-configurable capture_roles later? (v1: disease ["doctor"]; hospital_admin/super_admin always allowed.)
  4. Internationalization — English-only first packs?
  5. Clinical instrument licensing — commercial redistribution rights for NIHSS/mRS/etc. before GA of each pack?

References

Internal code

Path Relevance
database/migrations/2026_07_01_100000_create_care_patient_tables.php Patient, allergies, conditions, family, insurance
database/migrations/2026_07_02_100000_create_care_clinical_tables.php Visits, consultations, vitals, diagnoses
database/migrations/2026_07_03_100000_create_care_lab_and_prescription_tables.php Result values: string value only
app/Models/Consultation.php Status draft/completed, relations
app/Models/Diagnosis.php Pathway matching input
app/Models/VitalSign.php Typed vitals retained
app/Models/Concerns/BelongsToOwner.php Runtime tenant scope
app/Services/Care/ConsultationService.php save/complete/syncDiagnoses
app/Services/Care/PatientService.php Registration remains universal
app/Services/Care/CarePermissions.php Role abilities (extend)
app/Services/Care/PlanService.php Plan entitlements — not rollout flags
app/Services/Care/AuditLogger.php Clinical audit
app/Http/Controllers/Care/ConsultationController.php Web integration; vitals vs manage split pattern
app/Http/Controllers/Care/Concerns/ScopesToAccount.php Authz helpers
resources/views/care/consultations/show.blade.php Primary UI surface
resources/views/care/patients/_form.blade.php Demographics (no specialty)
config/care.php Enums, audit actions, plans
routes/web.php Route registration
tests/Feature/CarePatientTest.php, CareLabTest.php Test style to extend
database/seeders/ Currently empty of assessment packs — greenfield

External clinical instruments

  • NIH Stroke Scale (NIHSS), Modified Rankin Scale (mRS), Barthel, GCS, NYHA, CAT, mMRC, Hoehn & Yahr, UPDRS, MMSE/MoCA — with licensing review before commercial packaging.

PR Plan

Incremental, independently reviewable PRs. Merge order as listed.

Milestones

Milestone After PRs Demoable outcome
M1 — Universal intake PR 13 Register patient; complete structured universal intake on consultation; free-text symptoms still work
M2 — Pathways + stroke E2E PR 46 Save diagnoses → suggestions → activate stroke → complete NIHSS/mRS with scores
M3 — Breadth PR 6b8 Extra stroke instruments, diabetes, outcomes trends
M4 — API PR 9 Mobile/API parity

PR 1 — Assessment engine schema + domain models

  • Title: feat(assessments): add template-driven assessment tables and models
  • Depends on: none
  • Files: migration for templates, questions, assessments, answers (no scores table); catalog models without BelongsToOwner; runtime models with BelongsToOwner; minimal relation/uuid tests
  • Description: Additive schema only. Establishes catalog tenancy contract and uniqueness (code, version) for system templates.

PR 2 — AssessmentService + permissions + CareFeatures + web CRUD

  • Title: feat(assessments): service layer, permissions, rollout flags, and capture UI
  • Depends on: PR 1
  • Files:
    • AssessmentService (normalize invariants, capture_roles, draft idempotency, cancel, complete lock)
    • CareFeatures + settings.rollout.*
    • CarePermissions: assessments.view, assessments.capture, assessments.manage
    • AssessmentController, routes, Blade forms
    • config/care.php statuses + audit actions
    • Feature tests: nurse universal yes / NIHSS no; doctor + hospital_admin NIHSS yes; tenant 404; public UUID body fields
  • Description: Full capture lifecycle gated by rollout flag. No pathway tables yet.

PR 3 — Universal intake seed + consultation entry

  • Title: feat(assessments): seed universal intake and link from consultation
  • Depends on: PR 2
  • Files: database/data/assessments/universal_intake.v1.json, AssessmentTemplateSeeder, wire DatabaseSeeder / deploy note; consultation show section; patient timeline; dual narrative UI (symptoms + intake); tests
  • Description: M1 complete. Layer 1 structured overlay without patient column churn.

PR 4 — Scoring materialization

  • Title: feat(assessments): scoring service and care_assessment_scores
  • Depends on: PR 2
  • Files: only migration for care_assessment_scores (not in PR 1); AssessmentScore; ScoringService + sum_items / single_value; unit tests; complete path integration
  • Description: Materialize scores on complete. Required before specialty packs.

PR 5 — Clinical pathway registry + patient activation

  • Title: feat(pathways): clinical pathway registry and patient activation
  • Depends on: PR 2 (PR 3 recommended for realistic consultation UX)
  • Files: pathway migrations (template_code only on bindings); PathwayService, PathwayMatcher (full algorithm + tests: I63.9, ischaemic stroke, history unclear, exclude family history); pathways.manage; deactivate POST (not DELETE); suggestions endpoint on persisted diagnoses; consultation UI suggestions + “Save diagnoses & suggest”; activate creates required drafts; lockForUpdate uniqueness; CarePathwayTest
  • Description: Layer 2. Explicit activation; comorbidity-safe.

PR 6 — Stroke MVP content pack (NIHSS + mRS)

  • Title: feat(pathways): stroke MVP content pack (NIHSS, mRS)
  • Depends on: PR 4, PR 5 (PR 3 recommended)
  • Files: nihss.v1.json, mrs.v1.json, stroke pathway JSON with required bindings; golden scoring tests; consultation instruments list when stroke active
  • Description: M2 vertical slice. Do not include Barthel/GCS/swallow/clinical in this PR.

PR 6b — Stroke extended instruments

  • Title: feat(pathways): stroke extended instruments (Barthel, GCS, swallow, clinical)
  • Depends on: PR 6
  • Files: remaining stroke templates + pathway bindings (optional/required as product chooses); scoring tests
  • Description: Completes full stroke module without blocking E2E milestone.

PR 7 — Diabetes content pack

  • Title: feat(pathways): diabetes assessment content pack
  • Depends on: PR 6 (stroke E2E proven), PR 4, PR 5
  • Files: diabetes_core seed + pathway; optional lab HbA1c display; comorbidity tests (stroke + diabetes)
  • Description: Second module after stroke path is stable.
  • 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.