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.
72 KiB
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:
CarePermissionsroles (doctor,nurse, …) +ScopesToAccount/BelongsToOwner - Audit:
AuditLogger::record(...)with actions listed inconfig/care.php→audit_actions - Reports:
ReportService::clinicalReportgroups diagnosis descriptions only - Seeders:
database/seeders/currently has no assessment content-pack pattern (greenfield for this engine)
Pain points
- Unstructured HPI — symptoms are a textarea; no duration, pain score, or systematic functional status.
- No specialty instruments — stroke/diabetes/COPD scoring cannot be captured, scored, or trended.
- Diagnosis is dead data for workflow — recording “Ischaemic stroke” does not surface NIHSS or swallow assessment.
- Comorbidity gap —
care_patient_conditionsis a static list; it does not load assessment packs. - Longitudinal gap — vitals append rows per consultation; there is no first-class QoL, falls, adherence, or readmission outcome series.
- 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
- Universal first — register and consult any patient without selecting a specialty module.
- Diagnosis-activated pathways — linking a diagnosis (or explicit clinician selection) activates one or more clinical pathways and their assessment templates.
- Comorbidity-safe — N active pathways ⇒ N concurrent template sets on the same patient/consultation.
- Metadata-driven instruments — new diseases and guideline updates ship as seed/content packs, not migrations of clinical columns.
- Hybrid storage — keep typed tables for demographics, vitals, diagnoses, labs; use template answers for instruments and missing structured universal sections.
- Longitudinal by design — every assessment instance is dated, patient-scoped, optionally consultation/visit-linked, with status lifecycle.
- Fit existing patterns —
care_tables, UUIDs, soft deletes,owner_refon runtime data, organization/branch scope,CarePermissions,AuditLogger, service classes, Blade sections on consultation show. - Incremental delivery — ship engine + universal template first; pathways; scored stroke MVP (NIHSS + mRS); then more packs.
Non-Goals
- Full ICD-10/SNOMED browser in v1 — free-text + optional code remains; pathway matching uses configurable code/keyword rules, not a complete terminology server.
- Replacing typed vitals or lab catalog with EAV — those stay relational.
- All twelve specialty modules in the first release — Stroke MVP (NIHSS + mRS) then Diabetes; others follow as content packs.
- Treatment plan engine / care pathways as order sets — assessment only; orders stay prescriptions/investigations.
- Offline mobile form runtime — web + existing API patterns first.
- Clinical decision support alerts (e.g. thrombolysis eligibility) beyond computed scores and basic validation.
- Patient self-assessment portal in v1 (may reuse templates later).
- Migrating historical free-text symptoms into structured answers automatically.
- Org-level pathway match_rules overrides in v1 (platform rules only).
- 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 / 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):
- Validate raw payload against type +
validation/options. - Build row with only the authoritative column set; explicitly null the others.
- For
score_item+single_choice-style choices: client may send optioncode; server mapsoptions.choices[].score→value_number, and may also store code invalue_textonly if type weresingle_choice. For purescore_item, store score invalue_numberonly (code optional invalue_jsonas{"code":"1"}if audit of selection needed — v1: store score only invalue_number). - For
multi_choice: scores are not auto-summed unlessscoring_strategyhandles them (v1 disease instruments usescore_item, not multi_choice, for scored totals). calculatedfields (v1 deferred for formulas): if present, ignore client input; recompute only ifoptions.formulais absent — v1 ships no calculated questions. Reserve type for PR later; seed packs must not usecalculateduntil 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:
{
"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:
- All
is_requiredquestions have non-null authoritative values. - If strategy is
sum_itemsorsingle_value, allscore_itemquestions required for the instrument must be present (treat missing as 422). - On success: upsert
care_assessment_scores; auditassessment.completed. - Idempotent complete: if already
completed, return 422 (no re-complete) per KD-14. - Scoring failure:
Log::warningwithtemplate_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::save → syncDiagnoses (delete-recreate). Alpine form state is not the source for server suggestions.
v1 rules:
- 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. - Optional UX: button “Save diagnoses & suggest pathways” that POSTs consultation update then redirects/refetches suggestions (single CTA). Preferred in consultation UI copy.
- 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.
- Activation snapshot: store
activation_diagnosis_text(concat descriptions/codes at activate time). Never store diagnosis row IDs (IDs are unstable undersyncDiagnoses).
Consultation UI integration
Extend resources/views/care/consultations/show.blade.php with new sections (same card pattern as vitals/diagnoses), gated by CareFeatures:
- Universal assessment — link/form for incomplete universal intake for this visit (
$canCaptureAssessment). - Active pathways — chips; “Add clinical pathway” (
pathways.manage). - Suggested pathways — from saved diagnoses + match reasons; empty state: “Save diagnoses to see suggestions”.
- Disease assessments — drafts/completes for this consultation + patient; “Start NIHSS” if capture_roles allow.
- 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:
- Insert new
care_assessment_templatesrow with samecode, incrementedversion,is_current = true; mark previousis_current = false. - Historical assessments keep FK to the old template version (immutable definition).
- New assessments resolve via
template_code→is_current(pathway bindings never store versionedtemplate_id). - 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 = activeexists → return existing (idempotent activate) or 422 “already active” (prefer idempotent return of existing active row). - If
resolved/inactive→ create new row withactive(history preserved) OR reactivate same row — v1: create new active row after setting old to stay resolved/inactive (audit trail). Enforce at most oneactivevia transaction check (not partial unique index in MySQL without workarounds). - Optional DB aid: generated column
active_pathway_key=patient_idwhen 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)whenconsultation_idpresent; when no consultation, at most one draft per(patient_id, template_id)withconsultation_idnull andstatus=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)
{
"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):
- Route/controller: member must have
assessments.captureorassessments.manage(orassessments.viewfor reads;pathways.managefor pathway mutations). - 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_adminalways pass capture checks (owner/admin clinicians). They still need a route ability (captureormanage); both roles have*inCarePermissionstoday, so that is satisfied.assessments.managedoes not invent a second capture matrix — it only unlocks the same endpoints ascapturefor roles that hold manage (doctor already holds both). Template scope is only decided byassertCaptureAllowedabove.- 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
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}_nihssor unique(organization_id, code, version)with sentinelorganization_id = 0for 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, conditionscare_vital_signscare_diagnoses- investigation catalog/results (string
valueon result values unchanged) - prescriptions, bills
Migration strategy
- PR 1: templates, questions, assessments, answers (no scores).
- PR 4:
care_assessment_scores+ scoring service. - PR 5: pathway tables.
- Seed via
database/seeders/*+ JSON packs; wire intoDatabaseSeeder/ deploy runbook. - No backfill of historical consultations.
- 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:
{
"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):
{
"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:
- JSON conforming to schema above.
- Golden unit tests for scoring (e.g. mRS 3 → total 3; NIHSS fixture answers → known total).
- Attribution/licensing note in
meta.attribution(Open Question: commercial use of instrument IP — legal review before GA of each pack). - Seeder registration in
DatabaseSeederor documentedphp 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.phpactions. - Structured logs:
Log::warning/Log::erroron scoring failures and capture denials with fields:template_code,assessment_uuid,owner_ref(or org id),reason. - Optional reporting: later extend
ReportServicewith 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
- Internal — migrate + seed universal;
rollout.assessments_enginefor staging. - Pilot — pathways + stroke MVP (NIHSS/mRS).
- GA — default rollout on for new orgs; content pack runbook.
- 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 = falseon 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):
- Amend completed assessments (post-v1) — versioned amendments with reason vs permanent lock after v1?
- Lab value pull-through — how tightly to couple diabetes HbA1c display to
care_investigation_resultsin the diabetes pack? - Nurse NIHSS — org-configurable
capture_roleslater? (v1: disease["doctor"]; hospital_admin/super_admin always allowed.) - Internationalization — English-only first packs?
- 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 withBelongsToOwner; 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.manageAssessmentController, routes, Blade formsconfig/care.phpstatuses + 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, wireDatabaseSeeder/ 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_codeonly 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;lockForUpdateuniqueness;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_coreseed + 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_coreseed; per-patient outcome history (free); optional org-levelReportServiceanalytics 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 parallelingCareLabTesttenant/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
- New patient can be registered and consulted without selecting a specialty.
- Clinician can complete a universal intake assessment linked to a consultation (M1).
- After saving diagnoses, pathway suggestions appear with match reasons; activation supports multiple pathways (M2).
- Stroke pathway exposes NIHSS + mRS; completion stores answers + materialized score (M2).
- Adding a new instrument requires seed JSON + seeder only, no clinical column migration.
- Assessment mutations appear in audit log; tenant + branch isolation enforced in tests.
- Typed vitals/diagnoses/labs and free-text symptoms continue to work unchanged on
consultations/show. - Nurse can capture universal intake; nurse cannot start NIHSS; doctor and hospital_admin can.
- Completed assessment PUT returns 422; cancel works on drafts only.
- Web/API request bodies reference consultations/visits by UUID, resolved server-side to FKs.