# Clinic Management System — Master Architecture Document

**Status:** APPROVED 2026-08-01. Phase 0 (Foundation) is built and verified against this document.
**Version:** 1.0
**Date:** 2026-08-01
**Decisions D1–D12 (§19):** approved as recommended. Recorded in [docs/adr/](adr/README.md).
**Applies to:** Clinic Application (per-clinic install) + License Server (central, vendor-operated)

> Naming note: this document uses **Clinic App** for the software installed at each clinic and
> **License Server** for the vendor-operated central service. Commercial product naming, vendor
> namespace (`Clinic\`), and composer package names are open decisions (§16).

---

## Table of Contents

1. [Executive Summary & Architectural Principles](#1-executive-summary--architectural-principles)
2. [System Landscape](#2-system-landscape)
3. [Technology Decisions & Rationale](#3-technology-decisions--rationale)
4. [Application Layering (Clean Architecture in Laravel)](#4-application-layering-clean-architecture-in-laravel)
5. [Module System](#5-module-system)
6. [Folder Structure](#6-folder-structure)
7. [Database Architecture](#7-database-architecture)
8. [Core Domain Model (Phase 1)](#8-core-domain-model-phase-1)
9. [Service & Repository Architecture](#9-service--repository-architecture)
10. [API Architecture](#10-api-architecture)
11. [Security Architecture](#11-security-architecture)
12. [Licensing Architecture](#12-licensing-architecture)
13. [Auto-Update Architecture](#13-auto-update-architecture)
14. [Deployment & Operations](#14-deployment--operations)
15. [UI/UX Architecture](#15-uiux-architecture)
16. [Quality, Testing & Release Engineering](#16-quality-testing--release-engineering)
17. [Delivery Roadmap](#17-delivery-roadmap)
18. [Risk Register & Future Challenges](#18-risk-register--future-challenges)
19. [Open Decisions Requiring Your Approval](#19-open-decisions-requiring-your-approval)

---

## 1. Executive Summary & Architectural Principles

### 1.1 What we are building

Two independent Laravel 12 applications:

| | Clinic App | License Server |
|---|---|---|
| **Deployed** | Once per clinic, on the clinic's own cPanel/domain/MySQL | Once, on vendor infrastructure |
| **Owns** | All patient/clinical/financial data | Licenses, packages, releases, telemetry |
| **Knows about PHI** | Yes | **Never** |
| **Repository** | `clinic-app` | `clinic-license-server` |
| **Upgrade cadence** | Versioned releases, self-updating | Continuously deployed by vendor |

They communicate **only** over HTTPS REST, outbound from the Clinic App. The License Server never
initiates a connection to a clinic, never receives clinical data, and a clinic must remain fully
usable for patient care if the License Server is unreachable.

### 1.2 The three constraints that shape every decision

Most "enterprise Laravel" advice assumes a single SaaS deployment that you control. We do not have
that. Three constraints drive nearly every choice in this document:

1. **N independent installations you cannot SSH into.** A bad migration shipped to 800 clinics is
   not a rollback — it is 800 support tickets. This forces: reversible migrations, additive-only
   schema changes within a major version, pre-update backups, automatic rollback, and remote
   diagnostics.
2. **Shared hosting (cPanel).** No Supervisor, no Redis guarantee, possibly no `exec()`, possibly
   no `ZipArchive`, restricted memory, no root. This forces: database queue driven by cron, file
   cache, a web-based installer, a pure-PHP update path with graceful degradation, and a runtime
   module autoloader that does not require `composer dump-autoload` on the server.
3. **Clinical software carries liability.** A double-booked appointment is an inconvenience; a lost
   prescription, an overwritten clinical note, or a lockout during a patient consultation is a
   safety and legal problem. This forces: append-only clinical records (amendments, not edits),
   tamper-evident audit logs, view-access logging, gapless document numbering, and a licensing
   model that degrades to warnings — **never** to a locked door.

### 1.3 Principles (in priority order, for when they conflict)

1. **Patient safety and data integrity beat every other concern**, including licensing enforcement.
2. **Boring, explicit, readable code beats clever abstraction.** Ten-year maintainability means the
   next developer must understand it in an afternoon.
3. **Additive change over breaking change.** New columns, new modules, new endpoints — never
   silently changed semantics.
4. **A module is a product boundary, not a folder.** If two modules can only be understood
   together, they are one module.
5. **Abstract where we have a second implementation or a real seam** (storage, licensing transport,
   notification channels, module registry). Do not abstract for its own sake — a repository that
   exists only to call `Model::find()` is ceremony, and the pattern is applied here with a stated
   purpose (§9).
6. **The UI is one client among several.** Every use case is callable from Blade, from the REST
   API, from an Artisan command, and from a queued job, with identical behaviour and authorization.

---

## 2. System Landscape

```mermaid
flowchart LR
    subgraph Clinic1["clinic1.com (independent cPanel)"]
        A1[Clinic App]
        D1[(MySQL)]
        S1[[storage/app/private]]
        A1 --- D1
        A1 --- S1
    end

    subgraph Clinic2["clinic2.com (independent cPanel)"]
        A2[Clinic App]
        D2[(MySQL)]
        A2 --- D2
    end

    subgraph Vendor["Vendor Infrastructure"]
        LS[License Server]
        LD[(MySQL)]
        CDN[[Release Storage / CDN]]
        LS --- LD
        LS --- CDN
    end

    A1 -- "HTTPS REST (outbound only)" --> LS
    A2 -- "HTTPS REST (outbound only)" --> LS
    A1 -- "signed release download" --> CDN

    MOB[Future Mobile App] -- "HTTPS + Sanctum token" --> A1
```

### 2.1 Trust boundaries

| Boundary | Direction | Auth | Data crossing it |
|---|---|---|---|
| Clinic App → License Server | Outbound only | API key + HMAC request signature + install fingerprint | License key, domain, install UUID, app version, PHP/MySQL version, aggregate counts (users, patients — **counts only**) |
| License Server → Clinic App | **None.** Server never calls a clinic | — | — |
| Clinic App → Release storage | Outbound | Signed, time-limited URL issued by License Server | Release archive + signature |
| Mobile/3rd party → Clinic App | Inbound to the clinic's own domain | Sanctum personal access token, per-device, revocable | Full clinical data, scoped by policy |

**Hard rule:** no endpoint on the License Server ever accepts a patient name, identifier, diagnosis,
or document. Payloads are schema-validated and rejected on unknown fields, so an accidental leak in
a future version fails loudly instead of silently storing PHI on vendor infrastructure.

---

## 3. Technology Decisions & Rationale

### 3.1 Confirmed stack (as specified)

| Concern | Choice | Notes |
|---|---|---|
| Framework | Laravel 12 | LTS-ish cadence; PHP 8.3 minimum, 8.4 supported |
| Language | PHP 8.3+ | `declare(strict_types=1)` everywhere, typed properties, readonly DTOs, enums |
| DB | MySQL 8.0+ | 8.0 is the floor; see §7.9 for MariaDB/5.7 position |
| Front-end | Bootstrap 5 + jQuery + AJAX + Blade | Deliberate: hostable anywhere, no Node on the server, huge hiring pool |
| Authorization | spatie/laravel-permission | Roles + granular permissions, DB-backed, cached |
| Tables | Yajra DataTables (server-side) | Server-side mode mandatory — never client-side on clinical tables |
| PDF | barryvdh/laravel-dompdf | Invoices, prescriptions, reports |
| Images | Intervention Image | Avatars, scans, thumbnails |
| Calendar | FullCalendar | Appointment scheduling |
| Charts | Chart.js | Dashboards |
| Queue | `database` driver | Shared hosting has no Supervisor (§14.5) |
| Cache | `file` driver | With a cache abstraction so Redis is a config change later |
| Scheduler | Laravel Scheduler | One cron entry (§14.5) |

### 3.2 Additional dependencies proposed

| Package | Purpose | Justification |
|---|---|---|
| `laravel/sanctum` | API tokens for the future mobile app | First-party, token revocation per device |
| `laravel/fortify` *(optional)* | Auth backend primitives + 2FA | Alternative: hand-rolled auth controllers. Recommend Fortify for 2FA/password-reset correctness |
| `spatie/laravel-backup` | Pre-update + scheduled backups | Battle-tested; must be verified against shared-hosting constraints (mysqldump availability) |
| `spatie/laravel-activitylog` | **Rejected** — see §11.7 | We need a tamper-evident, hash-chained, PHI-aware audit log; we build it |
| `nwidart/laravel-modules` | **Rejected** — see §5.2 | Requires composer autoload regeneration on the target server |
| `larastan/larastan`, `laravel/pint`, `pestphp/pest`, `deptrac` | Dev only | Static analysis, style, tests, module boundary enforcement |
| `firebase/php-jwt` or native `sodium_*` | License token verification | Prefer native `sodium_crypto_sign_verify_detached` (Ed25519), zero dependency |

### 3.3 Explicitly rejected

- **SPA / Inertia / Livewire.** Adds a build step or WebSocket/long-poll expectations that shared
  hosting punishes. Blade + AJAX keeps the deploy artifact a zip file.
- **Multi-tenant packages (stancl/tenancy).** This product is *not* SaaS. One install = one clinic.
  Multi-**branch** is modelled inside the single database (§7.5), which is a different problem.
- **Redis/Horizon as a requirement.** Optional accelerator, never a dependency.
- **Source encoders (ionCube/SourceGuardian) in v1.** See §12.7 — open decision.

---

## 4. Application Layering (Clean Architecture in Laravel)

### 4.1 The layers

```
HTTP / CLI / Queue  (delivery mechanisms — interchangeable)
        │
        ▼
┌───────────────────────────────────────────────────────────┐
│  Controller (web) │ ApiController │ Command │ Job         │  ← coordination only
├───────────────────────────────────────────────────────────┤
│  FormRequest (validation + authorize)  →  DTO             │  ← input contract
├───────────────────────────────────────────────────────────┤
│  Service  /  Action        (use cases, transactions,      │  ← ALL business rules
│                             events, orchestration)         │
├───────────────────────────────────────────────────────────┤
│  Repository (writes + domain reads) │ QueryObject (reads) │  ← persistence
├───────────────────────────────────────────────────────────┤
│  Eloquent Model (schema, relations, casts, scopes)        │  ← data mapping only
└───────────────────────────────────────────────────────────┘
        │
        ▼
   MySQL │ Storage │ Mail │ License API  (infrastructure, behind interfaces)
```

### 4.2 Rules per layer

**Controller** — max ~15 lines per action. Allowed: resolve dependencies, map the validated request
to a DTO, call one service method, return a response/view/JSON. Forbidden: `if` on business
conditions, Eloquent, `DB::`, transactions, `auth()->user()->something` business logic.

**FormRequest** — every write endpoint has one. `rules()`, `messages()`, `attributes()`,
`prepareForValidation()`. `authorize()` delegates to a Policy — never inline role string checks.
Controllers consume `$request->validated()` or `$request->toDto()`, never `$request->all()`.

**DTO** — `final readonly class` with a `fromRequest()` named constructor and typed public
properties. This is what crosses the Service boundary, so the web layer and the API layer feed the
same use case with the same shape. No `array $data` in service signatures. Ever.

**Service** — one public method per use case (`createPatient`, `rescheduleAppointment`). Owns the
transaction boundary (`DB::transaction`), emits domain events, enforces invariants, throws domain
exceptions. Stateless, constructor-injected, depends on **interfaces**.

**Action** *(optional refinement)* — when a service grows past ~7 public methods or a use case has
substantial internal steps, extract a single-purpose invokable class
(`Modules/Billing/Actions/IssueInvoiceAction`). Services then compose actions. This is how we avoid
the 2,000-line "god service" that every Laravel project eventually grows.

**Repository** — interface in `Contracts/`, Eloquent implementation in `Repositories/`. Returns
models/collections/paginators — not arrays. One repository per aggregate root, not per table.

**Query Object** — read-optimized paths (DataTables feeds, dashboard widgets, reports) bypass the
repository and use a dedicated query class returning a Query Builder or a read DTO. Trying to force
a 12-column filtered, sorted, joined DataTables feed through a repository is where these
architectures rot. This is a deliberate CQRS-lite split: **writes go through services + repos;
complex reads go through query objects.**

**Model** — casts, relations, scopes, accessors, `$fillable`. No business rules, no static
finders used as a service layer, no notifications dispatched from model events.

### 4.3 Cross-cutting: domain events

Modules must not call each other's internals. Communication is one of exactly two forms:

1. **Published contract** — Module A depends on `Modules\Pharmacy\Contracts\StockServiceInterface`,
   resolved from the container. The interface lives in the *providing* module's `Contracts/`
   directory and is the module's public API. Everything else in the module is internal.
2. **Domain event** — `PrescriptionIssued`, `AppointmentCompleted`, `InvoicePaid`. The Pharmacy
   module listens; the Consultation module does not know Pharmacy exists. This is how future
   modules (Lab, Accounting, Telemedicine, AI) attach without editing existing modules.

Events are the default. Contracts are used only when a synchronous return value is required.

---

## 5. Module System

### 5.1 What a module is

A self-contained vertical slice: routes, controllers, services, repositories, models, migrations,
views, translations, permissions, menu entries, settings, and tests. A module can be:

- **enabled/disabled** at runtime (feature flag or license package),
- **installed/removed** by dropping a directory (via the update system),
- **dependent** on other modules, with the dependency graph validated at boot,
- **licensed** — present on disk but inert until the license grants its feature key.

### 5.2 Why a custom module kernel instead of `nwidart/laravel-modules`

`nwidart` registers module namespaces through `wikimedia/composer-merge-plugin`, i.e. autoloading is
resolved at `composer dump-autoload` time. Our plugin story is "the auto-updater drops a new module
into `Modules/` on a cPanel host with no shell". Regenerating the composer autoloader there is
fragile-to-impossible. Additionally, licensing-aware enable/disable, install/uninstall hooks, and
ordered cross-module migrations would all have to be layered on top anyway.

**Decision:** build a small (~400 LOC) module kernel we own:

- `modules_statuses.json` + a compiled manifest cache (`bootstrap/cache/modules.php`).
- A **runtime PSR-4 autoloader** registered in `ModuleServiceProvider::register()` from the
  manifest, so a newly-dropped module autoloads with no composer step.
- Each module ships `module.json` (name, key, version, requires, provides, feature key, migration
  path, permissions, menu) and a `ModuleServiceProvider`.
- Lifecycle hooks: `install()`, `enable()`, `disable()`, `uninstall()`, `upgrade(from, to)`.
- Boot order resolved by topological sort of `requires`; a missing/disabled dependency disables the
  dependent module with a clear admin-panel warning rather than a 500.

This is the single biggest "build vs buy" call in the document, and it is justified purely by the
cPanel + auto-update constraint. Flagged in §19 for your confirmation.

### 5.3 Module contract

```
Modules/Patients/
├── module.json                     # manifest: key, version, requires, feature, provides
├── Providers/
│   ├── PatientsServiceProvider.php # bindings, views, translations, migrations, policies
│   ├── RouteServiceProvider.php
│   └── EventServiceProvider.php
├── Contracts/                      # PUBLIC API of this module (interfaces only)
│   ├── PatientServiceInterface.php
│   └── PatientRepositoryInterface.php
├── Http/
│   ├── Controllers/{Web,Api/V1}/
│   ├── Requests/
│   ├── Resources/                  # API resources
│   └── Middleware/
├── Services/
├── Actions/
├── Repositories/
├── Queries/
├── DTOs/
├── Models/
├── Policies/
├── Events/  Listeners/  Jobs/  Notifications/
├── Enums/   Exceptions/
├── Database/
│   ├── Migrations/
│   ├── Seeders/
│   └── Factories/
├── Resources/
│   ├── views/
│   ├── lang/{en,ar}/
│   └── assets/{js,css}
├── Routes/{web.php,api.php,console.php}
├── Config/patients.php
└── Tests/{Feature,Unit}/
```

### 5.4 Boundary enforcement (automated, not aspirational)

A `deptrac` ruleset plus an architecture test in CI fails the build when:

- a module references another module's `Models\`, `Repositories\`, `Services\` (only `Contracts\`
  and `Events\` are importable across modules),
- a controller imports `Illuminate\Support\Facades\DB` or an Eloquent model directly,
- a model imports a service,
- core (`app/`) imports any `Modules\` namespace (core must never depend on a module).

Without this, module boundaries decay within three months. This is non-negotiable infrastructure.

### 5.5 Module catalogue

**Core (always installed, not licensable):**
`System` (settings, branches, sequences), `Auth` (users, roles, permissions, 2FA, sessions),
`Audit`, `Licensing`, `Updater`, `Backup`, `Notifications`, `Reporting` (report engine + exports).

**Phase 1 clinical/business:**
`Patients`, `Appointments`, `Consultations` (EMR/encounters), `Prescriptions`, `Billing`,
`Dashboard`.

**Phase 2+:** `Pharmacy`, `Laboratory`, `Radiology`, `Inventory`, `Accounting`, `HR`, `Payroll`,
`Telemedicine`, `PatientPortal`, `AI`, `MobileApi`, `Insurance`, `WhatsApp/SMS Gateway`.

Every Phase 2+ module maps to a **feature key** the License Server can grant per package — this is
the commercial packaging mechanism (Basic / Standard / Professional / Enterprise).

---

## 6. Folder Structure

### 6.1 Clinic App

```
clinic-app/
├── app/                              # CORE ONLY — framework glue + shared kernel
│   ├── Console/Commands/
│   ├── Exceptions/                   # Handler + base domain exceptions
│   ├── Http/
│   │   ├── Controllers/Controller.php
│   │   ├── Middleware/               # EnsureInstalled, EnsureLicensed, SetLocale,
│   │   │                             # ForceHttps, SecurityHeaders, AuditContext
│   │   ├── Requests/BaseFormRequest.php
│   │   └── Resources/BaseResource.php
│   ├── Models/                       # only truly global models (User, Branch, Setting)
│   ├── Modules/                      # THE MODULE KERNEL
│   │   ├── ModuleManager.php  ModuleRepository.php  ModuleManifest.php
│   │   ├── ModuleAutoloader.php  ModuleServiceProvider.php
│   │   └── Contracts/  Exceptions/
│   ├── Support/                      # shared kernel: Money, Sequence, Hashing,
│   │   │                             # BlindIndex, DateRange, Result
│   │   └── Traits/                   # HasUlid, BelongsToBranch, Auditable, HasSequence
│   ├── Foundation/                   # base Service, Repository, QueryObject, DTO, Enum
│   └── Providers/
├── Modules/                          # ALL FEATURES LIVE HERE
│   ├── System/  Auth/  Audit/  Licensing/  Updater/  Backup/  Reporting/
│   ├── Patients/  Appointments/  Consultations/  Prescriptions/  Billing/
│   └── ...
├── bootstrap/cache/                  # includes modules.php manifest cache
├── config/                           # app config + clinic.php, licensing.php, modules.php
├── database/
│   ├── migrations/                   # CORE tables only; module tables live in modules
│   └── seeders/                      # roles, permissions, settings, demo data (optional)
├── docs/                             # THIS document + ADRs + module specs
├── install/                          # web installer (requirements, DB, admin, license)
├── public/                           # index.php + built assets ONLY
├── resources/
│   ├── views/layouts|components|partials/   # the design system
│   ├── lang/{en,ar}/
│   └── assets/                       # global scss/js source
├── routes/
│   ├── web.php  api.php  console.php  install.php
├── storage/app/
│   ├── private/                      # PHI: patient files, lab reports, scans — NEVER public
│   ├── public/                       # logos, non-sensitive branding only
│   ├── backups/  updates/  license/
└── tests/{Feature,Unit,Architecture}/
```

### 6.2 License Server

```
clinic-license-server/
├── app/
│   ├── Domain/
│   │   ├── Licensing/   # License, Activation, Installation, DomainBinding
│   │   ├── Packages/    # Package, Feature, PackageFeature, Limits
│   │   ├── Releases/    # Product, Version, Release, Channel, Manifest, Signature
│   │   ├── Customers/   # Customer, Order, Subscription, Invoice
│   │   └── Telemetry/   # Heartbeat, UsageSnapshot, ErrorReport
│   ├── Http/Controllers/{Admin,Api/V1}/
│   ├── Services/        # LicenseIssuer, TokenSigner, ActivationService, UpdateResolver
│   └── Support/Crypto/  # Ed25519 signing (private key from env/KMS, never in repo)
├── database/
├── storage/releases/    # signed release archives (or S3)
└── ...
```

---

## 7. Database Architecture

### 7.1 Identity strategy

Every table gets **both**:

- `id` — `BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY`. Internal only. Fast joins, small indexes,
  clustered-index friendly.
- `ulid` — `CHAR(26)`, unique index. **The only identifier ever exposed** in URLs, API responses,
  QR codes, or exported files.

ULID over UUIDv4: lexicographically sortable, time-ordered (no index fragmentation), 26 chars vs 36,
and it does not leak sequential counts the way `id` does. Route model binding uses `getRouteKeyName()
=> 'ulid'`. This is decided now because retrofitting public identifiers after 500 installs is a
migration nightmare.

### 7.2 Money

`BIGINT` in **minor units** (cents/fils/piastres) + a `currency` char(3) on the owning document.
Never `FLOAT`/`DOUBLE`; `DECIMAL` only in reporting views. An immutable `App\Support\Money` value
object handles arithmetic, allocation/rounding (banker's rounding for tax splits), and formatting.
Multi-currency is not a Phase 1 feature, but the currency column exists from day one.

### 7.3 Time

All timestamps stored UTC. Clinic timezone is a setting; conversion happens in a presenter/cast at
the edge. Appointment slots store UTC `starts_at`/`ends_at` **plus** the originating timezone
string, because DST shifts otherwise silently move historical appointments.

### 7.4 Deletion policy

| Data class | Policy |
|---|---|
| Master/reference data (patients, doctors, services, drugs) | `SoftDeletes` + `deleted_by`, restorable, restore audited |
| Clinical records (encounters, notes, prescriptions, results) | **Never deleted or edited in place.** Amendments create a new version; the original stays. Status `active` / `amended` / `entered_in_error` |
| Financial documents (invoices, payments, receipts) | **Never deleted.** Corrections are credit notes / reversals |
| Audit log | Append-only, hash-chained (§11.7) |
| Operational/junk (sessions, jobs, cache, temp uploads) | Hard delete, pruned by scheduler |

Soft deletes require a unique-index strategy: unique constraints include `deleted_at` where a
soft-deleted row must not block reuse of a code (e.g. `UNIQUE(branch_id, code, deleted_at)`).

### 7.5 Multi-branch and multi-doctor readiness (from day one)

The single most expensive retrofit in this class of product is adding branches later. Therefore:

- `branches` table exists in v1. A fresh install seeds exactly one "Main Branch".
- **Every operational table carries `branch_id`** (patients, appointments, encounters, invoices,
  stock, …), NOT NULL, FK.
- `BelongsToBranch` trait + a global scope filters by the user's active branch; users have a
  `branch_id` and an optional `branch_users` pivot for multi-branch staff. Super Admin can switch
  branch context (stored in session, audited).
- Doctors are `users` with the Doctor role plus a `doctor_profiles` row (specialty, license number,
  signature image, consultation fee, working hours). Multi-doctor is therefore the default shape,
  and a single-doctor clinic is just N=1 — no special-casing.

The UI may hide branch switching when only one branch exists; the data model never assumes it.

### 7.6 Sequences and document numbering

Medical and financial documents need **gapless, per-branch, per-year** numbering (MRN, invoice no.,
prescription no., lab request no.). `MAX(id)+1` is unacceptable under concurrency.

`sequences(branch_id, key, period, current_value)` with `SELECT ... FOR UPDATE` inside the
transaction that creates the document. Format templates configurable per clinic
(`INV-{branch}-{YYYY}-{0}` by default; a longer run of zeros pads the counter). A dedicated `SequenceGenerator` service in `app/Support`.

### 7.7 Indexing strategy

- Every FK indexed. Every `ulid` unique. Every soft-deletable table indexed on `deleted_at` where
  it participates in hot queries.
- Composite indexes designed **from the query, left-to-right by selectivity**:
  `appointments(branch_id, doctor_id, starts_at)`, `patients(branch_id, deleted_at, created_at)`,
  `audit_logs(auditable_type, auditable_id, created_at)`.
- Search: `patients` gets a `FULLTEXT(name, phone_normalized)` index for MySQL 8, plus normalized
  `phone_e164` and `name_normalized` columns (accent/case folded) for reliable prefix search.
- No query in a list view without a covering index; enforced by a "slow query" test that runs
  `EXPLAIN` on the critical list endpoints in CI and fails on `type=ALL`.

### 7.8 Migration policy (critical for fleet upgrades)

1. Migrations are **forward-only in production** but every migration implements `down()` for local
   development.
2. Within a major version, schema changes are **additive**: add nullable column → backfill in a
   chunked, resumable job → switch code → drop the old column only in the next major version.
3. Never rename or retype a column in place on a table that can hold millions of rows on unknown
   hardware. `ALGORITHM=INPLACE` is not guaranteed on all hosts.
4. Every destructive migration requires a verified pre-update backup (the updater enforces this).
5. Migrations are idempotent-safe: guard with `Schema::hasColumn`/`hasTable` because interrupted
   shared-hosting updates happen.
6. Data migrations are queued jobs, not migration files — a 2-minute migration times out on shared
   hosting.

### 7.9 MySQL version floor

Target **MySQL 8.0+**. `utf8mb4_0900_ai_ci` collation, InnoDB, `ROW_FORMAT=DYNAMIC`.
MariaDB 10.6+ compatibility is a *nice-to-have*, and we avoid MySQL-8-only syntax (window functions
in application queries, `CHECK` constraints, functional indexes) unless there is a fallback. The
installer refuses to proceed below the floor with a clear message — silently supporting MySQL 5.7
would cost us `utf8mb4` index length pain and JSON performance for years.

### 7.10 Settings

`settings(key, value, type, group, is_encrypted, is_public)` — typed, cached in a single file-cache
entry, invalidated on write, exposed through a `Settings` facade with `Settings::get('clinic.name')`.
Modules register their own settings schema in `module.json`, so a new module's settings page is
generated, not hand-built.

---

## 8. Core Domain Model (Phase 1)

Key tables only; full DDL comes in the database design document after approval.

**System / Auth**
`branches`, `users`, `doctor_profiles`, `roles`, `permissions`, `model_has_roles`,
`role_has_permissions`, `settings`, `sequences`, `user_sessions`, `login_attempts`,
`password_histories`, `two_factor_secrets`, `notifications`.

**Patients**
`patients` (MRN, ulid, branch_id, name, dob, gender, phone_e164, blood_group, national_id_encrypted,
national_id_index, address, emergency contact, is_active), `patient_allergies`,
`patient_chronic_conditions`, `patient_medications`, `patient_documents`, `patient_relatives`,
`patient_insurance`.

**Appointments**
`appointments` (branch, patient, doctor, service, starts_at/ends_at UTC, status enum, source,
booked_by, cancellation_reason), `doctor_schedules` (weekly template), `schedule_exceptions`
(leave/holiday), `waiting_list`, `queue_tokens` (walk-in queue number per doctor per day).
Double-booking prevented by a service-level overlap check **inside a transaction with a row lock on
the doctor's day**, plus a DB unique constraint on `(doctor_id, starts_at)` as a backstop.

**Consultations (EMR)**
`encounters` (visit), `encounter_vitals`, `encounter_notes` (versioned: `version`, `supersedes_id`,
`status`), `diagnoses` (ICD-10 code + free text), `procedures`, `encounter_attachments`,
`referrals`, `sick_leaves`, `follow_ups`.

**Prescriptions**
`prescriptions`, `prescription_items` (drug, dose, frequency, duration, route, instructions),
`drugs` (local formulary — seeded, extendable), `drug_interactions` *(Phase 2, needs a licensed
data source — see §18)*.

**Billing**
`services` (billable catalogue + price), `invoices`, `invoice_items`, `payments`,
`payment_methods`, `credit_notes`, `discounts`, `cash_sessions` (shift open/close per cashier),
`daily_closures`.

**Audit**
`audit_logs`, `record_access_logs` (who *viewed* which patient record — required by most health
data regimes and by our own liability posture).

**Licensing / Updates**
`license_state` (single row), `license_events`, `update_checks`, `update_history`,
`backup_records`.

---

## 9. Service & Repository Architecture

### 9.1 Interfaces and binding

```
Modules/Patients/Contracts/PatientServiceInterface.php      ← other modules may import
Modules/Patients/Contracts/PatientRepositoryInterface.php   ← internal to the module
Modules/Patients/Services/PatientService.php
Modules/Patients/Repositories/EloquentPatientRepository.php
```

Bound in `PatientsServiceProvider::register()`. Consumers type-hint the interface. This gives us:
testability (fake repository in unit tests), a stable cross-module contract, and a seam for a future
read-replica or caching decorator — the three reasons the pattern earns its keep here.

### 9.2 Base classes

`App\Foundation\BaseRepository` provides `find`, `findOrFail`, `findByUlid`, `create`, `update`,
`delete`, `restore`, `paginate`, `withRelations`, `firstWhere` — typed, generic over the model. A
concrete repository only adds domain-meaningful methods (`findActiveByPhone`,
`countNewThisMonth`). We do **not** expose `where()` chains from repositories; that leaks query
building into services.

### 9.3 Transactions, events and side effects

- The **service** opens the transaction. Repositories never do.
- Side effects that must not roll back (emails, SMS, license calls, file writes to remote storage)
  are dispatched **after commit** — `DB::afterCommit()` or queued listeners with
  `ShouldQueue` + `afterCommit = true`.
- Domain events are dispatched from the service, carrying DTOs/IDs — never Eloquent models (a model
  in a serialized queued job is a stale-data bug waiting to happen).

### 9.4 Errors

Domain exceptions extend `App\Exceptions\DomainException` and carry a stable machine code
(`PATIENT_MRN_DUPLICATE`). The exception handler renders them as a flash message + redirect for web,
and as RFC-7807-style JSON for API — one mapping, two presentations. HTTP status codes are decided
in the handler, not thrown from services.

### 9.5 Authorization

Policies per model, registered by each module's service provider. Permissions are granular strings
(`patients.view`, `patients.create`, `patients.delete`, `billing.refund`,
`consultations.view_others`), grouped by module, seeded on module install. Roles are **collections
of permissions**, editable by the Super Admin — so "future roles must be easy to add" is satisfied
by data, not code. Sensitive permissions (delete, refund, settings, user management) are flagged and
require re-authentication (password confirmation) within the last 15 minutes.

---

## 10. API Architecture

### 10.1 Shape

- Namespace: `/api/v1/...`, versioned in the URI. `v1` is frozen once a mobile app ships; new
  behaviour goes to `v2` or behind an additive field.
- Auth: Sanctum personal access tokens, one per device, named, revocable from the user's profile and
  from the admin panel. Token abilities mirror permissions.
- Every API controller calls **the same service** as its web counterpart. If an API endpoint needs
  logic the web UI does not have, that logic goes in the service, not the controller.
- Responses: `JsonResource` / `ResourceCollection`, consistent envelope:

```json
{ "data": {...}, "meta": {...}, "links": {...} }
{ "error": { "code": "APPOINTMENT_SLOT_TAKEN", "message": "...", "details": {...} } }
```

- Pagination mandatory on every collection (default 25, max 100). Cursor pagination for high-volume
  feeds.
- `Idempotency-Key` header honoured on POST for payments and appointment creation — mobile clients
  retry on flaky networks and must not double-charge.
- Rate limits per token and per IP, tighter on auth endpoints.

### 10.2 Why this guarantees the mobile app works later

The rule "no business logic in controllers" is what makes this true, and it is enforced by the
architecture tests in §5.4, not by discipline. A feature is considered done only when its use case is
reachable without an HTTP session.

### 10.3 Outbound API client (to License Server)

`App\Support\Http\LicenseClient` — a thin, retrying, timeout-bounded (5s connect / 10s total) client
with circuit-breaker behaviour: after 3 consecutive failures it backs off for an hour. **Every call
site must tolerate failure.** The clinic keeps working.

---

## 11. Security Architecture

| Threat | Control |
|---|---|
| SQL injection | Eloquent/Query Builder with bindings only. Raw SQL requires a code-review exception and parameter binding. `DB::raw` with user input fails CI (static analysis rule). |
| XSS | Blade `{{ }}` escaping by default; `{!! !!}` requires an HTML Purifier pass and is grep-audited in CI. AJAX responses set `X-Content-Type-Options: nosniff`; JS uses `.text()` not `.html()` for user data. |
| CSRF | `VerifyCsrfToken` on all web routes including AJAX (token in `<meta>`, sent via global jQuery `ajaxSetup`). API uses tokens, not cookies. |
| Mass assignment | `$fillable` whitelists (never `$guarded = []`), and services receive DTOs — request arrays never reach `create()`/`update()`. |
| File upload | MIME sniffed from content (not extension), extension allow-list, size caps, randomized storage names, stored in `storage/app/private` (outside webroot), served through a controller behind a policy + short-lived signed URL. Images re-encoded through Intervention (strips EXIF and embedded payloads). PDFs never rendered inline from user upload without `Content-Disposition: attachment`. |
| Session hijacking | `secure`, `http_only`, `SameSite=Lax` cookies; session regenerated on login and privilege change; absolute + idle timeout (configurable, default 30 min idle for clinical stations); session invalidated on password change; active-session list with remote logout. |
| Brute force | Throttled login (per IP + per username), progressive delay, account lockout with admin unlock, all attempts logged, optional 2FA (TOTP) per user, mandatory for Super Admin (configurable). |
| Rate limiting | Named limiters: `login`, `api`, `export`, `search`, `password-reset`. |
| Password storage | bcrypt (cost tuned per host at install) / argon2id when available; password history to prevent reuse; configurable policy (length, complexity, expiry). |
| Sensitive data at rest | Laravel `encrypted` casts for national ID, insurance number, and configured free-text fields. Searchable encrypted fields get an HMAC-SHA256 **blind index** column so exact-match lookup works without decryption. `APP_KEY` rotation procedure documented (§14.8). |
| Transport | HTTPS forced in production; HSTS; installer warns loudly if the domain has no valid certificate. |
| Headers | CSP (script-src self + nonce), X-Frame-Options DENY, Referrer-Policy same-origin, Permissions-Policy minimal. |
| Privilege escalation | Policies on every action; role assignment restricted to Super Admin; a user can never grant a permission they do not hold. |
| Insider misuse | `record_access_logs` — every patient record view is logged with user, IP, and reason where applicable. Admin report: "who accessed patient X". |

### 11.7 Audit log design

`audit_logs`: `id`, `ulid`, `branch_id`, `user_id`, `impersonator_id`, `event` (created/updated/
deleted/restored/viewed/login/logout/exported/settings_changed), `auditable_type`, `auditable_id`,
`old_values` (JSON, redacted), `new_values` (JSON, redacted), `ip`, `user_agent`, `route`,
`request_id`, `created_at`, `previous_hash`, `hash`.

- **Hash chain:** `hash = sha256(previous_hash || canonical_json(row))`. Any row edited or deleted
  after the fact breaks the chain, and a scheduled verifier reports it. We cannot prevent a hosting
  admin with DB access from tampering; we can make tampering detectable, which is what actually
  matters in a dispute.
- **Redaction:** password fields, tokens, and encrypted attributes are never written to
  `old_values`/`new_values` — logged as `["REDACTED"]`.
- Written via an `Auditable` trait on models plus explicit `Audit::log()` calls in services for
  non-model events. Retention configurable; archived (not deleted) to compressed monthly files.

### 11.8 What we are honest about

- We are not claiming HIPAA/GDPR *certification* — that is a property of the deployment and the
  clinic's own processes. We provide the technical controls (access control, audit trail, encryption
  at rest for identifiers, export/erasure tooling, breach-relevant logging) and document the clinic's
  responsibilities. This distinction belongs in the sales material too.
- Data residency, backup custody, and TLS termination are the clinic's hosting responsibility. The
  installer surfaces this explicitly.

---

## 12. Licensing Architecture

### 12.1 Data model (License Server)

`customers`, `orders`, `subscriptions`, `packages`, `features`, `package_features`,
`package_limits` (max_users, max_branches, max_patients — nullable = unlimited), `licenses`
(key, package, status, starts_at, expires_at, max_activations), `activations`
(license, domain, install_uuid, fingerprint, activated_at, last_seen_at, status),
`heartbeats`, `usage_snapshots`, `products`, `versions`, `releases`, `channels`,
`release_files` (path, sha256, signature), `api_clients`, `license_events`.

### 12.2 Activation flow

```
1. Admin enters license key in the installer or Settings → License.
2. Clinic App POSTs { key, domain, install_uuid, fingerprint, app_version, php, mysql }
   signed with HMAC(shared secret derived from the key).
3. License Server validates key, status, expiry, activation count, and domain binding.
4. Server returns a SIGNED LICENSE TOKEN (Ed25519 detached signature over a compact JSON claim set):
   { license_id, domain, install_uuid, package, features[], limits{},
     issued_at, expires_at, grace_days, nonce }
5. Clinic App verifies the signature with an EMBEDDED PUBLIC KEY, stores the token
   encrypted in storage/app/license + a hash in the DB, and caches parsed claims.
```

The private key exists only on the License Server (env/KMS). A cracked clinic install cannot mint a
valid token without it.

### 12.3 Runtime enforcement

- A cached `LicenseState` object answers `isValid()`, `hasFeature('pharmacy')`,
  `withinLimit('users', $count)`, `daysUntilExpiry()`.
- `EnsureLicensed` middleware on admin/settings routes; `@feature('pharmacy')` Blade directive;
  `RequiresFeature` middleware on module routes; module kernel refuses to boot unlicensed modules.
- Limits are checked in the **service layer** at creation time (adding the 11th user on a 10-user
  package), with a clear upgrade message — never a silent failure.

### 12.4 Heartbeat and offline behaviour

Daily scheduled `license:heartbeat`:
sends `{ install_uuid, domain, app_version, counts: {users, patients, appointments_30d} }`,
receives a refreshed token, feature flags, remote config, and the update manifest pointer.

**Degradation ladder (this is a deliberate product-safety decision):**

| Condition | Behaviour |
|---|---|
| Token valid | Normal |
| Server unreachable, token not expired | Normal, silent |
| Server unreachable > grace period (default 14 days) | Persistent admin banner; full clinical function retained |
| License expired | Read-only for *administrative* modules (settings, reports export, new user creation). **Patient care flows — appointments, encounters, prescriptions, invoicing — keep working**, with a banner |
| License revoked (fraud/chargeback) | Same as expired + a prominent notice, plus vendor-side commercial process |

**We never lock a clinic out of its own patient data.** Beyond being ethically indefensible in a
medical setting, it is a legal and reputational hazard. Enforcement pressure comes from update
access, support access, and administrative degradation — not from hostage-taking.

### 12.5 Domain binding

Bind to registered domain + `install_uuid`. Allow `www.` and one staging subdomain per license.
Domain change requires a self-service "transfer" (rate-limited, logged, N per year) — clinics
legitimately migrate hosts, and a rigid binding generates support load out of proportion to the
piracy it prevents.

### 12.6 Remote configuration & feature flags

The heartbeat response may carry `config` overrides (e.g. `updates.channel`, `support.contact`,
`telemetry.enabled`) and per-install feature flags. Applied to a signed local config cache; only a
whitelisted key set is accepted, so a compromised License Server cannot push arbitrary config.

### 12.7 Realistic threat position

PHP source ships readable. A determined reseller can strip license checks. Our objectives, in order:
(1) make compliance easy for honest customers, (2) make tampering detectable, (3) tie ongoing value
(updates, support, cloud features) to a valid license so piracy decays. Optional hardening —
encoding the `Licensing` module with ionCube/SourceGuardian — is a business decision with real costs
(hosting compatibility, debugging pain, customer trust). Flagged in §19.

---

## 13. Auto-Update Architecture

*(Architecture now; implementation in a later phase — but the pieces below must exist in v1 so the
first update never requires a manual migration.)*

### 13.1 Release format

A release is a zip containing the full application (including `vendor/`, built assets, and a compiled
autoloader) plus `release.json`:

```json
{
  "version": "1.4.0", "channel": "stable", "released_at": "...",
  "min_from_version": "1.2.0", "php": ">=8.3", "mysql": ">=8.0",
  "requires_backup": true, "breaking": false,
  "files": [{ "path": "...", "sha256": "..." }],
  "modules": { "pharmacy": "1.4.0" },
  "post_update": ["migrate", "cache:rebuild", "modules:sync"],
  "signature": "<Ed25519 over the manifest>"
}
```

Shipping `vendor/` removes any dependency on Composer being available on the clinic host — a
hard requirement on cPanel.

### 13.2 Update pipeline (in the Clinic App)

```
check → resolve (server decides the next allowed hop) → preflight → backup →
maintenance mode → download → verify signature + per-file hashes → stage →
swap → migrate → rebuild caches → health check → exit maintenance → report
                                     │
                                     └─ any failure → automatic rollback
```

- **Preflight:** disk space, PHP/MySQL version, writable paths, `ZipArchive` present (fallback:
  pure-PHP unzip), DB backup succeeded, no queued jobs in flight, no active clinical session heuristic.
- **Staged swap:** extract to `storage/updates/staging/<version>`, then move the *current* app aside
  and promote staging — an atomic-ish directory rename, not an in-place overwrite.
- **Rollback:** previous directory + DB dump retained for N days; a single `update:rollback` command
  and an admin-panel button.
- **Stepped upgrades:** the server resolves a path (1.2 → 1.3 → 1.4) rather than allowing arbitrary
  jumps, so migrations always run in a tested sequence.
- **Health check:** boots the app, runs a read query, hits an internal `/up` endpoint, verifies the
  module manifest — before leaving maintenance mode.
- Manual path always available: download the zip, upload via cPanel File Manager, run
  `/install/update` in the browser. Never leave a clinic stranded because cron was disabled.

### 13.3 Version discipline

Semantic versioning. **Major = breaking schema/API**, and majors are rare and heavily communicated.
Every release must upgrade cleanly from any release in the previous 12 months, verified in CI against
seeded databases of each supported prior version.

---

## 14. Deployment & Operations

### 14.1 cPanel layout

```
/home/<user>/
├── clinic_app/            ← the Laravel application (NOT web accessible)
│   ├── app/ Modules/ storage/ vendor/ ...
└── public_html/           ← document root
    ├── index.php          ← bootstraps ../clinic_app
    ├── .htaccess
    ├── build/  css/  js/  img/
    └── storage/           ← symlink (public assets only; PHI is never here)
```

If a host forbids document-root changes, a fallback layout with the app inside a `public_html/app`
directory protected by `.htaccess`/`deny from all` is documented — less clean, sometimes necessary.

### 14.2 Web installer

Clinics do not have SSH. `/install` provides:
requirements check (PHP version + extensions: `pdo_mysql`, `mbstring`, `openssl`, `gd`/`imagick`,
`zip`, `fileinfo`, `curl`, `intl`; writable paths; `APP_KEY` generation) → DB credentials + connection
test → run migrations & seeders → clinic profile (name, logo, timezone, currency, locale) → Super
Admin account → license activation → cron instructions → **lock file** (`storage/installed.lock`),
after which `/install` 404s and re-running requires manual removal.

### 14.3 Environment

`.env` written by the installer, `APP_DEBUG=false` enforced in production, `APP_ENV=production`.
Config/route/view caching is part of the release build **and** re-run post-update. A "diagnostics"
admin page reports environment health (versions, extensions, permissions, cron last run, queue
backlog, license status, disk usage) — this is what support will actually use to debug remotely.

### 14.4 Storage

All PHI files in `storage/app/private/{patients,encounters,lab,invoices}/{ulid-sharded-path}`.
Access exclusively via a controller: policy check → audit `viewed` → stream. Filenames are ULIDs;
original names are stored in the DB. `Storage::disk('private')` everywhere — a future S3/local-NAS
disk is a config change. **No hardcoded paths, no `public_path()` for patient data, ever.**

### 14.5 Cron, queue and scheduling

Single cPanel cron entry:

```
* * * * * /usr/local/bin/php /home/<user>/clinic_app/artisan schedule:run >> /dev/null 2>&1
```

Scheduled tasks: `queue:work --stop-when-empty --max-time=50` every minute (shared hosting has no
Supervisor), license heartbeat (daily, jittered), update check (daily), backups (nightly),
appointment reminders, audit-chain verification (weekly), log/temp pruning, report caches.

A `cron_heartbeats` record lets the admin panel warn "scheduler has not run in 3 hours" — the single
most common misconfiguration in this deployment model.

### 14.6 Backups

Nightly DB dump + storage archive, retention configurable, downloadable from the admin panel,
optional off-site (S3/Dropbox/Google Drive) per clinic. Pre-update backup is mandatory and blocking.
Restore is a documented, tested procedure — an untested backup is not a backup, so a `backup:verify`
command restores into a scratch database and asserts row counts.

### 14.7 Observability without SSH

- Structured logs (daily rotation, PHI-redacted).
- In-app log viewer for admins (read-only, filtered).
- Opt-in error reporting to the License Server: exception class, message, stack trace with paths
  normalized, app version, PHP version — **never** request payloads or PHI. Opt-in, disclosed, and
  toggleable.
- A support bundle export (config snapshot, diagnostics, redacted recent logs) the clinic can send us.

### 14.8 Key management

`APP_KEY` loss = unreadable encrypted fields. The installer stores a key fingerprint, warns about
backup, and we ship `encryption:rotate` (re-encrypt all encrypted attributes and blind indexes under
a new key, chunked and resumable) before any customer ever needs it.

---

## 15. UI/UX Architecture

### 15.1 Design intent

Clinical software is used 8 hours a day by staff who are not looking at it — they are looking at a
patient. Therefore: high information density, keyboard-first, no animation, no decorative
whitespace, strong typographic hierarchy, and status conveyed by shape+text as well as colour
(colour-blind safe). Target: a receptionist registers a walk-in in under 30 seconds without touching
the mouse.

### 15.2 Structure

- Fixed left sidebar (module navigation, permission-filtered, generated from module manifests),
  slim top bar (branch switcher, global patient search, quick-add, notifications, user menu).
- **Global search is the primary navigation** — MRN / name / phone, `/` to focus, arrow-key select.
- Blade component library in `resources/views/components`: `<x-card>`, `<x-data-table>`,
  `<x-form.input>`, `<x-modal>`, `<x-page-header>`, `<x-stat>`, `<x-empty-state>`,
  `<x-confirm-delete>`. Modules must compose these, never hand-roll markup — this is what keeps 20
  modules looking like one product.
- One CSS entry point (Bootstrap 5 + a thin theme layer of CSS custom properties). Dark mode is a
  variable swap, deferred but not designed out.
- RTL support built in from day one via Bootstrap 5 RTL + logical properties. Retrofitting RTL after
  40 screens exist costs 10x. (Depends on target market — §19.)
- Print stylesheets are first-class: prescriptions, invoices, lab requests, and labels have real
  print layouts, and thermal/A5 formats are configurable per clinic.

### 15.3 JS conventions

`resources/assets/js/app.js` + one module file per feature. A small `Clinic` namespace with
`Clinic.http` (jQuery AJAX wrapper: CSRF header, error toast, 419-session-expired handling, loading
state), `Clinic.table` (DataTables defaults), `Clinic.modal`, `Clinic.form` (validation error
rendering from the standard error envelope). No inline `<script>` with business logic. Assets are
built (Vite) at *release build time* — the clinic host never needs Node.

### 15.4 Accessibility & i18n

Labels on all inputs, focus states, ARIA on modals/toasts, target WCAG 2.1 AA. All user-facing
strings in `lang/` from the first line of code — never hardcoded English.

---

## 16. Quality, Testing & Release Engineering

| Gate | Tool | Threshold |
|---|---|---|
| Style | Laravel Pint (PSR-12 + custom) | Zero diff |
| Static analysis | Larastan | Level 6 in v1, level 8 target |
| Architecture | Deptrac + custom Pest tests | Zero violations (§5.4) |
| Tests | Pest | Feature tests for every use case; unit tests for services/value objects; ≥70% line coverage, 100% on billing, licensing, and clinical-record services |
| Upgrade | CI job | Fresh install + upgrade from each supported prior version |
| Security | `composer audit`, dependency review | No known-vulnerable dependencies in a release |
| Performance | `EXPLAIN` assertions + N+1 detection (`preventLazyLoading` in tests) | No `type=ALL` on list endpoints; zero lazy loads in tests |

Release pipeline: tag → install deps `--no-dev -o` → build assets → strip dev files → generate
manifest with per-file hashes → sign with Ed25519 → upload to release storage → register the version
on the License Server → phased rollout (internal → beta clinics → 10% → all).

**Documentation is a deliverable, not an afterthought:** ADRs in `docs/adr/`, a module authoring
guide, an API reference, an installation guide, an admin manual, and per-role user guides.

---

## 17. Delivery Roadmap

| Phase | Contents | Exit criteria |
|---|---|---|
| **0. Foundation** | Laravel skeleton, module kernel, base Service/Repo/DTO/Query classes, exception handling, audit engine, settings, branches, sequences, UI component library, layouts, CI + architecture tests | A "Hello Module" can be generated, enabled, licensed, audited and rendered |
| **1. Identity & Access** | Users, roles, permissions, 2FA, sessions, login security, profile, activity | A Super Admin can create every role and log in securely |
| **2. Patients** | Registration, MRN, search, profile, documents, allergies, history | Register + find a patient in <30s |
| **3. Appointments** | Doctor schedules, calendar, booking, walk-in queue, reminders, status flow | No double-booking under concurrent load test |
| **4. Consultations (EMR)** | Encounters, vitals, notes (versioned), diagnoses, procedures, attachments | Amendment trail provable |
| **5. Prescriptions** | Formulary, prescription builder, print/PDF | Printed prescription meets local legal format |
| **6. Billing** | Services catalogue, invoices, payments, discounts, cash sessions, daily closure | Cashier shift reconciles to the cent |
| **7. Reporting & Dashboard** | Report engine, exports, KPIs, charts | Reports paginate & export at 100k rows |
| **8. Licensing & Updates** | License client, heartbeat, feature gating, updater, backups | Full update 1.0 → 1.1 on a real cPanel host, with rollback |
| **9. Hardening & Release** | Security review, performance pass, installer polish, docs, packaging | First commercial install |
| **Phase 2+** | Pharmacy, Laboratory, Inventory, Accounting, HR, Telemedicine, Patient Portal, Mobile API, AI | Each ships as an independently installable, licensable module |

The License Server is built in parallel, minimally, at Phase 0 (licenses + activation) and completed
at Phase 8 (packages, releases, analytics, customer portal).

---

## 18. Risk Register & Future Challenges

| # | Risk | Impact | Mitigation |
|---|---|---|---|
| 1 | **Fleet migration failure** — a bad migration across hundreds of installs | Severe | Additive-only schema policy, mandatory pre-update backup, automatic rollback, phased rollout, CI upgrade matrix |
| 2 | **Support without access** — cannot reproduce a clinic's bug | High | Diagnostics page, opt-in error reporting, support bundle export, deterministic version fingerprint |
| 3 | **Shared-hosting variance** — disabled `exec`, no `ZipArchive`, low `memory_limit`, cron disabled | High | Preflight checks, pure-PHP fallbacks, manual update path, documented minimum host spec, a pre-sale "host compatibility checker" script |
| 4 | **Version fragmentation** — clinics stuck on 1.0 forever | High | Stepped upgrade paths, support policy tied to N-2 versions, in-app upgrade nagging, free minor updates |
| 5 | **Customization pressure** — every clinic wants one special field | High (kills the product) | Settings + custom-fields engine + module hooks + per-clinic theme; a firm "no forks" policy. Custom work becomes a paid module, never a patch to core |
| 6 | **Clinical/legal liability** — a lost note or wrong dose | Severe | Append-only clinical records, versioned notes, audit trail, view logging, no destructive edits, clear disclaimers on decision support |
| 7 | **Drug/ICD data licensing** — real interaction and coding databases are commercially licensed and expensive | Medium | v1 ships a clinic-editable local formulary and optional ICD-10 (public) codes; commercial drug databases become a paid integration, never bundled |
| 8 | **Localization/RTL retrofit** | Medium | i18n and RTL from line one (§15.2) |
| 9 | **Concurrency** — double-booking, duplicate invoice numbers, race on stock | Medium | Transactional row locks, DB unique constraints, sequence table, idempotency keys |
| 10 | **Data growth** — 10 years of encounters on a 1 vCPU host | Medium | Indexing discipline, pagination everywhere, archival/partitioning strategy for audit and encounters, report pre-aggregation tables |
| 11 | **Piracy** | Medium (commercial) | Signed tokens, tamper evidence, value-in-updates strategy; optional encoding (§12.7). Accept that some leakage is a cost of doing business |
| 12 | **APP_KEY loss** | High (unrecoverable PHI fields) | Installer warning, key fingerprint, backup guidance, rotation command |
| 13 | **Timezone/DST correctness in scheduling** | Medium | UTC storage + origin timezone on appointments, DST tests |
| 14 | **Regulatory divergence per country** (prescription format, invoice/tax rules, e-invoicing mandates, data residency) | High | Country "profile" packs — templates, tax rules, formats — as data + optional modules, not core branching |
| 15 | **Team scaling / knowledge concentration** | Medium | ADRs, module authoring guide, enforced boundaries, tests as documentation |

---

## 19. Open Decisions Requiring Your Approval

These change the work materially. My recommendation is given for each; say "approved" to take all
recommendations, or override individually.

| # | Decision | Options | My recommendation |
|---|---|---|---|
| D1 | **Module kernel** | (a) custom kernel with runtime autoloader, (b) `nwidart/laravel-modules` | **(a)** — required for composer-free plugin drop-in on cPanel (§5.2) |
| D2 | **Primary market & languages** | English only / English + Arabic (RTL) / other | **English + Arabic with RTL from day one** if the Middle East is a target — it is ~2% extra cost now and ~20% later |
| D3 | **MySQL floor** | 8.0+ / support 5.7 & MariaDB 10.4 | **8.0+**, installer blocks below |
| D4 | **License enforcement on expiry** | Hard lock / degraded admin, clinical retained | **Degraded** (§12.4) — patient care never blocked |
| D5 | **Source protection** | Plain PHP / ionCube on Licensing module / full encoding | **Plain PHP in v1**; revisit after first 50 sales |
| D6 | **Auth scaffolding** | Laravel Fortify / hand-rolled | **Fortify** for 2FA and password-reset correctness |
| D7 | **Repository pattern scope** | Every model / aggregate roots + query objects for reads | **Aggregate roots + query objects** (§9) |
| D8 | **Telemetry** | None / opt-in aggregate counts + opt-in error reports | **Opt-in, disclosed**, counts only, never PHI |
| D9 | **Repo layout** | Two separate repos / monorepo | **Two repos** — different release cadences and access levels |
| D10 | **Product name & vendor namespace** | — | Needed before Phase 0 (affects namespaces, package names, license branding) |
| D11 | **Billing/tax model** | Simple VAT / multi-tax + country e-invoicing hooks | **Multi-tax capable schema in v1**, single-tax UI — schema now, UI later |
| D12 | **Phase 1 scope confirmation** | Roadmap §17 phases 0–9 | Confirm, or tell me which modules must move into v1 |

---

## Approval & Build Status

Approved 2026-08-01 with all §19 recommendations accepted. Delivered since:

| Deliverable | Status |
|---|---|
| ADR set for D1–D12 | Done — [docs/adr/](adr/README.md) |
| Module authoring guide | Done — [docs/MODULE-AUTHORING.md](MODULE-AUTHORING.md) |
| Phase 0 — foundation, module kernel, core schema, audit engine, UI system, quality gates | Done, all gates green |
| Phase 1 — Identity & Access (users, roles, permissions, profile, 2FA, sessions, login security) | Done — `Modules/Auth`, all gates green |
| Phase 2 — Patients (register, MRN, duplicates, allergies, problem list, medications, contacts, documents, global search) | Done — `Modules/Patients`, all gates green |
| Phase 3 — Appointments (rotas, availability, booking, calendar, walk-in queue, status flow) | Done — `Modules/Appointments`, all gates green |
| Phase 4 — Consultations (encounters, versioned SOAP notes, observations, diagnoses) | Done — `Modules/Consultations`, all gates green |
| Phase 5 — Prescriptions (formulary, prescribing with allergy checking, printed prescription) | Done — `Modules/Prescriptions`, all gates green |
| Phase 6 — Billing (price list, invoicing with tax and discounts, payments, credit notes, cash till) | Done — `Modules/Billing`, all gates green |
| Laboratory (test catalogue with reference ranges, ordering, specimens, results with verification) | Done — `Modules/Laboratory`, all gates green. Brought forward from the "Phase 2+" list at the customer's request |
| Phases 7–9 (Reporting, Licensing & Updates, Hardening) | Not started |

Phase 1 shipped as the `Modules/Auth` core module: staff CRUD with archive and
restore, status changes, administrator password reset and account unlock, role
CRUD with a permission matrix grouped by owning module, self-service profile,
password policy with reuse history, two-factor enrolment, and an active-session
list with remote sign-out. The escalation rules behind all of it are recorded in
[ADR 0008](adr/0008-privilege-escalation-guard.md).

Phase 2 shipped as the `Modules/Patients` module: registration with gapless
per-branch MRNs, two-tier duplicate detection ([ADR 0009](adr/0009-patient-identity-and-duplicates.md)),
append-only allergies, problem list and medications ([ADR 0010](adr/0010-clinical-records-are-append-only.md)),
emergency contacts, documents on the private disk with content-sniffed uploads
and policy-gated streaming, and the topbar global search. It also added three
kernel capabilities that every later module inherits: module permissions
declared in the manifest are created on install and reconciled on every sync, a
factory-name resolver so module factories are found without per-model
boilerplate, and `Relation::morphMap` entries contributed by modules rather than
listed in core config.

Phase 3 shipped as `Modules/Appointments`: weekly rotas with effective dates,
leave and holiday exceptions, computed availability, booking with three
overlapping defences against double-booking, reschedule-by-replacement, the
consultation status machine, a walk-in queue, a FullCalendar month view and a
day list. Doctor profiles landed in `Modules/Auth`, where they belong.

It is also the first module needing data owned by two others, which turned the
"no cross-module models" rule from cheap to real. The answer is published
directories returning summary DTOs with batch lookup
([ADR 0012](adr/0012-cross-module-directories.md)); the reverse direction uses
events, so Patients dispatches `PatientArchived` and Appointments cancels the
diary without either module importing the other. Timezone and concurrency
decisions are recorded in [ADR 0011](adr/0011-scheduling-time-and-concurrency.md).

Phase 4 shipped as `Modules/Consultations`: encounters linked optionally to a
booking, SOAP notes with a draft/signed boundary and full version chains on
amendment ([ADR 0013](adr/0013-clinical-notes-are-versioned.md)), observations
with derived BMI and out-of-range marking, and diagnoses with explicit
certainty. Opening an encounter from an appointment moves that booking to "in
consultation" and closing it completes the booking — both inside one
transaction, through Appointments' published directory, so the two can never
disagree about whether the patient is being seen.

It also demonstrates the third and last cross-module pattern. Reads go through
directories, notifications through events, and now *synchronous writes* through
a published contract taking an id. Consultations never holds an `Appointment`
or a `Patient`.

Phase 5 shipped as `Modules/Prescriptions`: a clinic-editable formulary carrying
active ingredients, drafting and issuing with an allergy check on every line, a
draft/issued boundary after which the row is frozen, cancel-and-reissue for
corrections, and a DomPDF prescription carrying the prescriber's name and
registration number.

Two decisions in it are worth reading before extending the module. The allergy
check is name and ingredient matching only, and says so in the code, in the UI
and in [ADR 0014](adr/0014-allergy-checking-is-name-matching.md) — it warns and
demands a written reason rather than blocking, because a block moves the
prescription onto paper where nobody sees it. And the drug name, generic,
strength and form are *copied onto the prescription line* rather than read
through the formulary link: the formulary is editable, and a prescription
written in 2026 must still read in 2031 exactly as it was signed.

It also added the last extension point the module system was missing. The
prescribing panel appears on the consultation screen without Consultations
knowing this module exists, through a named **screen slot**
([ADR 0015](adr/0015-screen-slots-for-module-panels.md)). Laboratory, billing and
imaging all want a panel on that same screen and none of them will need to edit
it. `Modules` may now extend each other in all four directions with no import
crossing a boundary: reads through directories, notifications through events,
synchronous writes through published contracts, and screens through slots.

Phase 6 shipped as `Modules/Billing`: a per-branch price list with tax rates in
basis points, invoicing with line and bill-level discounts and inclusive or
exclusive tax, payments with an idempotency key against the double-click that
charges a patient twice, reversals and refunds as signed rows, credit notes, a
cashier's till that reconciles to the minor unit, and printed invoice, receipt
and credit-note documents.

The money rules are recorded in
[ADR 0016](adr/0016-money-and-financial-corrections.md) and are the reason this
phase looks heavier than it is. Three of them run through everything: every
amount is an integer and every rate a ratio of integers, applied through
`Money::multiplyRatio` so no financial figure ever passes through a float;
every total is computed on write and stored, so an invoice keeps saying what was
owed on the day it was issued whatever happens to the price list afterwards; and
nothing financial is ever edited or deleted — a mistake before money moved is a
void that keeps its number, a mistake afterwards is a credit note, and a payment
is corrected by a second row pointing back at the first. All three are enforced
at the model, not only in the service.

The till is the same idea applied to a shift. The expected figure comes from the
payment rows, the counted figure is entered independently, and the variance is
stored exactly as it falls; nothing in the service adjusts a session to make it
balance.

The Laboratory module shipped next, ahead of the roadmap order, as
`Modules/Laboratory`: a test catalogue with panels and population-specific
reference ranges, ordering with priority and clinical details, specimen
collection with gapless accession numbers and first-class rejection, result entry
with automatic flagging, a verification gate before anything reaches a clinician,
amendment by versioning, recorded acknowledgement of critical values, and printed
request and report documents.

Two safety decisions carry it, both recorded in
[ADR 0017](adr/0017-laboratory-results-and-the-charging-port.md). A reference
range is a property of a test *and a population* — sex and an age band in days,
matched most-specific-first, copied onto the result — because one pair of numbers
would mark half the healthy women in a clinic as anaemic while missing a
genuinely anaemic man. And a result is *released*, not saved: a number typed at a
bench is preliminary, invisible to clinicians and absent from the printout until
somebody verifies it, after which it can only be amended into a new version.

It also forced the last cross-module pattern. Ordering a test should charge for
it, but Laboratory importing Billing would mean a clinic that never bought
billing cannot boot the laboratory it did buy. Core now owns
`App\Foundation\Billing\ChargeCollector` with a silent no-op default that Billing
replaces — the same arrangement as the licence gates. Modules can therefore meet
in five directions with no import crossing a boundary: reads through directories,
notifications through events, synchronous writes through published contracts,
screens through slots, and charges through the port.

Structural corrections since Phase 0, all caught by the gates:

- The licence contracts (`FeatureGate`, `LicenseLimitGate`) moved from the
  module kernel to `App\Foundation\Licensing`. Services must consult them, and a
  service reaching into the kernel was the wrong direction. `NavigationBuilder`
  moved the other way, into `App\Modules\Navigation`, and now takes an
  `Authorizable` instead of a `User` so the kernel stays free of application
  models. The layer graph is acyclic again.
- Modules gained a `Support/` directory for specifications shared between
  validation and services — `PasswordPolicy` is the first. A form request has no
  business importing from `Services/`, and Deptrac said so.

Two things stated in this document are deliberately deferred and not yet built,
so nothing here is mistaken for shipped:

- **Licensing** (§12) — the contracts, the feature gate and the degradation
  configuration exist and the module kernel already gates on them. The signed
  token exchange with the License Server is not implemented; `UnrestrictedFeatureGate`
  is bound in its place.
- **Auto-update** (§13) — the architecture holds (versioned config, module sync,
  additive migration policy, backups disk). No updater code exists yet.

Recommended next: complete Phase 1's user and role management UI, then Patients
(Phase 2), which is the first module that exercises the full stack against real
clinical data.
