# 03 — System Architecture

## 1. Target stack (proposed; to be confirmed against the server, see 09 §3)
| Component | Target | Notes |
|---|---|---|
| PHP | **8.4 minimum; 8.5 if the server and all libraries support it** | Dev and prod must share the same minor version |
| Database | **MariaDB 11.4 LTS or 11.8 LTS** (owner picks per OS/cPanel compatibility) | Same major in dev and prod. Features used must also exist in the chosen version |
| Web server | Apache (cPanel EasyApache) with `mod_rewrite`, `mod_headers` | |
| Dependencies | Composer, built on dev/CI; `vendor/` shipped in release archive | No Composer or Node required on server |
| Required PHP extensions | `pdo_mysql`, `mbstring`, `openssl`, `sodium`, `intl`, `ctype`, `json`, `fileinfo`, `zip`, `gd` (or `imagick`), `curl` | `exec`/`proc_open` optional (only improves backups) |
| Tooling (dev only) | PHPUnit, PHPStan, PHP-CS-Fixer | |

Rules: do not use a feature unavailable in the declared minimums; CI/dev matrix runs on the exact target versions. Anything the server cannot reach becomes a server-upgrade task, not a lowered target.

## 2. Principles
1. Tenant isolation is structural, not conventional (see §4).
2. Controllers thin → services own rules/transactions → repositories own SQL.
3. Server-side authorisation at every layer: route, service, export, file, job.
4. No hidden state: dependencies injected through a small container.
5. Everything persistent, nothing faked: no hardcoded metrics, no placeholder actions.
6. VPS-portable: no cPanel-specific code outside `deploy/` and documented adapters.

## 3. Repository layout (no files created in Phase 0)
```
public/            only web root: index.php, assets/, sw.js, manifest endpoint, .htaccess
app/Core/          Router, Container, Request/Response, Config, Db, Migrator, Validator, View, Csrf, Clock, Ulid, Logger
app/Security/      Gate, Crypto (envelope), KeyRing, Session, Throttle, Redactor
app/Tenancy/       TenantContext, TenantResolver, TenantScopedRepository
app/Modules/<Name>/{Controllers,Services,Repositories,Views,Policies,Events,Jobs}
database/migrations/  (per-module ordered migrations; arrive in Phase 1+)
database/seeds/
config/            defaults only; secrets are NOT here
storage/           outside docroot: logs, cache, uploads (encrypted), backups-staging
keys/              outside docroot, mode 0600 (or env vars)
cron/              run.php (single entrypoint), cli.php (migrate, backup, diagnose)
tests/             Unit, Integration, Isolation, Security, E2E
docs/
```
cPanel: point the (sub)domain docroot to `public/`. Fallback for a docroot that cannot move: a root `.htaccess` that rewrites into `public/` and denies everything else. Owner controls the server, so the preferred layout is feasible.

## 4. Multi-tenancy
- **Model:** shared database, shared schema, `tenant_id` on every business-owned table.
- **Constraint pattern:** every parent has `UNIQUE(tenant_id, id)`; every child FK is composite `(tenant_id, parent_id) → parent(tenant_id, id)`. A child row therefore cannot reference another tenant's parent at the database level.
- **Resolution:** `TenantResolver` runs once per request after authentication. Active tenant = the authenticated session's selected **membership** (user ↔ tenant). Subdomain/custom domain/path slug is only a *hint* used to pick among the user's memberships or to brand pre-auth pages; it never grants access. Client-supplied tenant IDs, hidden fields and cookies are ignored for authorisation.
- **Scoping:** modules never receive a raw PDO. They get `TenantScopedRepository` subclasses whose query builder always adds `tenant_id = :ctx` and rejects statements lacking it. Raw SQL is allowed only in reviewed repository methods that carry `tenant_id` in the WHERE clause; a static test scans repositories for unscoped tables.
- **Public IDs:** URLs use ULIDs (`public_id`), not sequential integers; lookups are always `(tenant_id, public_id)`. Unknown or foreign IDs return 404 (not 403) to avoid oracle leaks.
- **Jobs/webhooks:** every queued job and provider event stores `tenant_id` and re-establishes `TenantContext` from it; a job without a tenant is rejected unless explicitly platform-scoped.
- **Files:** storage path `storage/uploads/{tenant_public_id}/...`, access only through an authorised controller (never direct URL).
- **Origin isolation (design-review change):** tenant-controlled content (templates, notes, uploaded SVGs, theme values) must not share a browser origin with other tenants. Default: **one subdomain per tenant** (`{slug}.example.com`, wildcard certificate; the owner controls DNS and server so this is feasible). Cookies are host-only (no `Domain` attribute), so a session, localStorage and service worker never cross tenants. A central auth host (`auth.example.com`) handles login and tenant switching and hands off to the tenant host with a signed, single-use, 60-second token. Path-based tenancy (`/t/{slug}`) remains a documented fallback only, with extra mitigations; it is not recommended. Uploaded files and generated documents are served from a separate cookieless host (`files.example.com`) or with `Content-Security-Policy: sandbox`.
- **Platform realm:** platform admins live in a separate table/guard/route prefix. Cross-tenant support access uses `support_grants` (justification, scope, expiry ≤ 4h default, audited, visible to the tenant owner).
- **Future isolation option:** `TenantContext` also carries a connection descriptor, so a tenant could later be pointed to a dedicated database without service changes.
- **Tests first:** the Phase 1 isolation suite (see 10) exists before any business module.

## 5. Request lifecycle
`public/index.php` → bootstrap (config, error handler, redaction) → session (DB-backed) → router → middleware chain [HTTPS/headers → CSRF (state-changing) → auth → tenant resolve → throttle → Gate permission] → controller → service (transaction, audit) → repository → response (escaped view or JSON). Exceptions map to safe error pages with a correlation ID; internals never leak.

## 6. Configuration
Precedence (low → high): platform defaults < preset snapshot (copied at onboarding, versioned) < tenant overrides. Each setting has a typed definition (type, constraints, default, sensitivity). Writes are validated, versioned (`tenant_setting_versions`) and audited; reads are cached per request. Secrets (SMTP, provider keys) are stored encrypted (C4), never in config files shared via git.

## 7. Authentication and sessions
Argon2id passwords (parameters tuned on the actual server, recorded in ADR). DB-backed sessions, ID rotation on login/privilege change, `Secure; HttpOnly; SameSite=Lax` cookies, idle + absolute timeouts per tenant policy, device list and revoke. CSRF synchroniser tokens. Throttling by (account, IP) with exponential backoff, uniform responses to prevent user enumeration. Email verification and reset use single-use hashed tokens with expiry. TOTP MFA with recovery codes (secrets encrypted C4); optional per tenant policy, mandatory for platform admins.

## 8. Authorisation
See [07-roles-permissions.md](07-roles-permissions.md). Permission = `resource.action`. `Gate::allows(user, permission, context)` evaluates role grants plus **scopes**: branch, assigned-client, sensitive-field groups, monetary approval limit. Enforced in middleware (route declares required permission), re-checked in services (defence in depth), in export builders, in file streaming, and in jobs acting on behalf of a user. Navigation hiding is cosmetic only.

## 9. Cryptography summary
Authenticated encryption (XChaCha20-Poly1305 via libsodium; AES-256-GCM via OpenSSL fallback), versioned envelope, per-tenant data keys wrapped by a master key outside the DB and docroot, blind indexes, rotation. Details: [06](06-data-classification-encryption.md).

## 10. Money
Integer minor units + ISO-4217 currency per document; calculation in a `Money` value object (no floats; rounding mode and tax-inclusive/exclusive rules are explicit per tenant). Issued invoices store full snapshots (lines, tax, customer details, branding refs). Payments/allocations/credit notes/refunds are append-only; corrections are new rows referencing the original. Statuses are separate state machines: invoice, payment, membership, access entitlement, with a coordinating service (no implicit coupling).
Provider events: authenticated (signature), stored in `provider_events` with unique `(provider, event_id)` for idempotency, processed in a transaction, and uncertain outcomes are held as `pending_reconciliation` and resolved by a reconciliation job. Test-mode and live-mode credentials are separate configs; production forbids the test provider (startup check).

## 11. Queue and scheduler
Single cron entry (`* * * * * php cron/run.php`) with a time budget (default 50 s) and a lock. Table `jobs` (tenant_id, type, payload, run_at, attempts, lease_until, status). Claim with `SELECT … FOR UPDATE SKIP LOCKED` (supported by MariaDB 10.6+, so valid for the target stack) inside a short transaction, with lease expiry for crashed workers. Retries with backoff and a max-attempt dead-letter state. Scheduled tasks (renewals, reminders, reconciliation, backups) are rows created idempotently.

## 12. Email
Two independent SMTP transports (A primary, B fallback; host, port, encryption, auth, sender, encrypted credentials). `email_outbox` rows carry an idempotency key and state: `queued → sending → sent | failed_retry | failed_final | unknown`. If the SMTP session ends after DATA without a clear response, state is `unknown` and **is not auto-retried through the other transport** unless the tenant opts in (duplicate-vs-loss trade-off documented). Delivery events (accepted by relay, bounce if received via webhook/bounce mailbox later) are separate from "sent". "Queued" is never displayed as "delivered". Admin diagnostics: connection tests, last error, queue depth, age of oldest message.

## 13. Files and documents
Uploads validated by size, extension, sniffed MIME and image re-encoding where applicable; stored outside docroot with random names; sensitive documents (C3) encrypted with a per-file data key wrapped by the tenant key; served via authorised streaming with audit entries; `Content-Disposition` and `X-Content-Type-Options: nosniff` always set. Malware scanning is a documented limitation (optional ClamAV hook if the server provides it).

## 14. Frontend and PWA
Server-rendered PHP templates with auto-escaping helper; progressive enhancement JavaScript as ES modules (no build step required at runtime; optional dev bundling). SVG icons from a local sprite with consistent size and stroke, accessible labels. Strict CSP (no inline script or style; per-tenant theme tokens are served as a versioned external stylesheet `/theme/{version}.css`, which also makes theme rollback cache-safe), referrer policy, frame-ancestors none. PWA: dynamic per-tenant manifest, service worker that precaches static shell and public assets only; authenticated HTML/JSON are `network-only`; visible online/offline indicator. Offline write operations are out of MVP (D-07). Platform limits (iOS push, background sync) are documented in [KNOWN-LIMITATIONS.md](KNOWN-LIMITATIONS.md).

## 15. Observability
Structured logs (JSON lines, correlation ID per request, redaction pass), audit log separate from operational log, health endpoint/CLI (DB, queue lag, disk, last backup, cron heartbeat), error alerts to platform admin email.

## 16. Error handling
Typed exceptions (Validation, Authorization, NotFound, Conflict, DomainRule). Services throw; a central handler renders HTML or JSON problem responses. Unexpected errors log with correlation ID and show a generic message.

## 17. Testing strategy
PHPUnit with a real MariaDB test database (same major as prod), transactions for isolation per test, factories. Suites: Unit, Integration, **Isolation** (every repository/route/export/file/job/webhook), Security (CSRF, XSS, SQLi probes, session fixation, throttling), Financial, Encryption, Mail, Backup/restore, PWA (service-worker logic tests), Accessibility (automated checks + manual checklist). Static: PHPStan level max in core, custom rule for unscoped queries.

## 18. Documented conflicts with hosting/requirements (and resolutions)
| Conflict | Resolution |
|---|---|
| "Do not require a permanently running worker" vs async mail/jobs | Cron-driven batch worker every minute; latency ≤ ~60 s documented |
| `mysqldump` needs command execution | Dual exporter (09 §6) |
| Audit tamper-protection vs one DB user | Owner controls server: create separate DB users (app: INSERT/SELECT on audit; maintenance user for retention purge). If unavailable: hash-chain detects tampering but cannot prevent it |
| Offline check-in vs confidentiality | Deferred; requires signed-token design (D-07) |
| Application-layer encryption vs server compromise | Limits documented in 06 §7 |
