# 14 — Engineering Conventions

The written conventions document required by [12 §7](12-stack-and-platform.md): with 31
developers and no framework, the risk is 31 styles in one codebase. The framework would
have imposed these; this document does instead. **PR review enforces it.**

---

## 1. Runtime

| Rule | Detail |
|---|---|
| PHP **8.2 minimum**, branch and centre on the **same minor version** | Doc 12 §4. CI floor is 8.2; pin the exact version after the hosting check confirms the host's. |
| `declare(strict_types=1);` in every PHP file | No silent coercion in money-handling code. |
| Framework-free backend; focused Composer packages where they earn it | Current allowlist: `vlucas/phpdotenv`, `mike42/escpos-php`, `khaled.alshamaa/ar-php`, `guzzlehttp/guzzle` (Phase 4), `phpoffice/phpspreadsheet` (Phase 9). Adding a package needs kernel-stream review. |

## 2. Repository layout

```
src/Kernel/      the frozen kernel (doc 16) — router, http, db, migrations,
                 validation, auth/rbac, audit, money, support
src/Domain/      business logic SHARED by branch and centre (Phase 2+)
src/Centre/      centre-only platform code (cron jobs, backup, config)
src/Branch/      branch-service-only code (Phase 3+)
public/          centre web entrypoint
cron/            cron dispatcher + jobs (`php cron/run.php <job>`)
migrations/      central/ (MySQL) and branch/ (SQLite) numbered SQL files
tests/           PHPUnit; mirrors src/ structure
tools/           operational tools (hosting check, restore drill, print test)
build/           release + deploy scripts
docs/            these documents — the spec is 00–13, engineering docs 14+
```

## 3. Layering — the three load-bearing rules

1. **The domain layer knows nothing about HTTP** (doc 12 §7). Domain code takes values,
   returns values, throws exceptions. Handlers translate to/from HTTP.
2. **Modules communicate through the domain layer, never each other's tables**
   (doc 12 §10). Billing never `SELECT`s from a POS table; it calls the orders module.
3. **All SQL goes through `Cafe\Kernel\Db\Db`** — prepared statements only, and every
   SQL-touching test extends `DialectTestCase` so it runs against **both** SQLite and
   MySQL. Dialect-specific SQL in feature code is a review reject; if a dialect
   difference is unavoidable, it lives behind a kernel helper.

## 4. Naming

| Thing | Convention | Example |
|---|---|---|
| DB tables/columns | `snake_case`, plural tables | `role_assignments.valid_from` |
| PHP classes | `PascalCase`, one class per file, PSR-4 | `Cafe\Kernel\Money\CashRounding` |
| Methods/variables | `camelCase` | `permittedBranchScope()` |
| Capabilities | `module.action` dot-path | `sale.charge_to_account` (doc 01 §3) |
| Cron jobs | `module:verb` | `backup:dump`, `sync:process` (doc 12 §5) |
| Migrations | `NNNN_snake_name[.dialect].sql` | `0001_kernel_baseline.mysql.sql` |
| Bilingual fields | `name_ar` / `name_en`; **Arabic required** | Doc 10 §6 |
| Timestamps | `*_at_utc` (UTC), `*_at_local`, `business_day` | Doc 10 §3 |

## 5. Error handling

- Exceptions, never error codes or `false` returns. PDO runs in `ERRMODE_EXCEPTION`.
- Kernel exceptions extend `Cafe\Kernel\Exception\KernelException`; entrypoints map them
  to the one JSON error envelope: `{"error": {"code", "message", "fields?"}}`.
- **No silent catch.** A caught exception is either handled meaningfully or rethrown.
  `catch (\Throwable) {}` is a review reject.
- User-facing message translation (Arabic-first) happens at the UI layer; exceptions and
  validator errors carry stable machine codes.

## 6. Validation

Every boundary (API handler, form controller, sync ingest) validates with
`Cafe\Kernel\Validation\Validator` **before** touching domain logic, and passes only
`validated()` data onward. Unknown rule names throw — a typo must fail loudly.

## 7. Database rules

1. **Every schema change is a numbered migration file** (doc 12 §7). No manual SQL on any
   database, ever — including production hotfixes.
2. Migration versions are **unique and ascending**; the runner rejects a version below the
   highest applied. Renumber your migration when you rebase, don't force it.
3. **Primary keys are UUIDs generated on the creating device** (doc 10 §1);
   human-facing numbers are separate columns.
4. **Soft delete only** (doc 10 §5): `status` columns or `revoked_at_utc`-style markers,
   never `DELETE` of operational records.
5. **Financial documents are immutable** (doc 10 §5): no `UPDATE` on committed orders,
   charges, invoices, payments, payroll runs, stock movements. Corrections are new
   opposing records. *Reviewers: an `update()` call on any financial table is the
   defining bug of this class of system — reject it.*
6. **Balances and quantities are derived, never stored as mutable totals** (doc 10 §5).
   Caches must be rebuildable from the ledger.
7. **Every query for branch-owned data takes an explicit branch scope**
   (doc 01 §3 rule 1) — obtained from `Rbac::permittedBranchScope()`. A missing scope is
   an error, never "all branches".

## 8. Money

- Only `Cafe\Kernel\Money\Money` represents amounts: **integer minor units + currency**.
  A `float` touching money anywhere is a review reject (doc 10 §2).
- IQD scale 0, USD scale 2 — defined once in `Money::CURRENCIES`.
- Cash rounding via `CashRounding::round()` **on the cash tender leg only**, recording the
  returned difference. Never round invoices or line items.
- Cross-currency math via `Fx::convert()` with the rate snapshotted onto the transaction.

## 9. Audit

Every state-changing action calls `Audit::write()` (doc 01 §4) inside the same
transaction as the mutation. Minimum always-audited list (doc 01 §4): price changes,
credit-limit changes, voids, refunds, discounts, write-offs, stock adjustments, shift
variance, payroll edits, user/permission changes, no-sale drawer opens. Approvals carry
`approved_by_user_id ≠ actor_user_id` — enforced by kernel and schema.

## 10. Testing

- **PHPUnit from day one** (doc 12 §7). New code lands with tests; the billing engine and
  money handling carry the heaviest coverage — that is where a bug costs real money.
- SQL-touching tests extend `DialectTestCase` (SQLite + MySQL). In CI a skipped MySQL
  test **fails** the build — the parity rule cannot be skipped there.
- Tests are deterministic: injected clocks (`?DateTimeImmutable $now`), no `sleep()`,
  no dependence on wall time or test order.
- The backup→restore round trip (`BackupRestoreTest`) stays in the suite permanently:
  every PR re-rehearses the restore drill.

## 11. Git, PRs, CI

- `main` is protected. All work goes through PRs with at least one review;
  kernel/`migrations/` changes need a kernel-stream reviewer.
- **CI must be green on both dialects before merge** (doc 12 §10 rule 4).
- Commits are small and message-first ("why" over "what"); no direct pushes to `main`;
  no `--force` on shared branches.
- Nobody FTPs to production by hand (doc 12 §8) — with 31 developers, that is how
  production drifts from `main`. Deploys go through `build/release.sh` + `build/deploy-ftps.sh`.

## 12. Configuration & secrets

- Config via `.env` (phpdotenv), never committed; `.env.example` documents every key.
- No secrets in code, migrations, tests, or fixtures. Passwords/PINs only as hashes
  (doc 10 §7).
- `vendor/` is **not committed**; the release artifact bundles it (doc 12 §8 allows
  either — bundling was chosen; see D2).

---

## 13. Decision log

Where docs 00–13 left an implementation choice open, the choice and its reason are
recorded here. Changing one is a PR against this file.

| # | Decision | Reason |
|---|---|---|
| D1 | Cash rounding rounds to the **nearest** increment, half away from zero | Doc 10 §2 fixes the increment (250 IQD) and the recording, not the direction; "rounding differences are an expected component of shift variance" implies both signs occur. Confirm with the owner before Phase 3 ships receipts. |
| D2 | `vendor/` bundled into the release artifact, not committed | Keeps diffs reviewable; doc 12 §8 permits either. |
| D3 | Sessions are PHP-native (cookie) behind a `SessionStore` interface | Smallest thing that works on shared hosting; swappable without touching `Auth`. |
| D4 | Migration DDL strategy: **per-dialect variant files** (`.mysql.sql` / `.sqlite.sql`), common file where identical | Each database only ever runs one engine; a cross-dialect DDL abstraction is complexity with no payer. |
| D5 | Role→capability matrix lives in code (`Capabilities::MATRIX`), not in a table | Doc 01 §3 fixes the six roles; code keeps the matrix versioned, reviewed, and testable. Extending it is an additive PR. |
| D6 | `branch_id` columns in kernel tables get FK constraints in Phase 2 | The `branches` table is Phase 2 master data; adding the FKs is part of that migration. |
| D7 | Role-assignment validity dates compare against the **branch-local (Asia/Baghdad) date** | Day-granularity access windows read naturally to the people scheduling cover shifts. |
| D8 | Assignment revocation is a `revoked_at_utc` marker | Soft delete only (doc 10 §5); keeps who-had-access-when answerable. |
| D9 | The organization id is configuration (`ORG_ID` env), not a table | Doc 01 §1: exactly one organization. A table with one row earns nothing yet. |
| D10 | PIN-reachable capability set = the POS/KDS/stock rows of the matrix, incl. `payment.receive` | Doc 01 §2 ("PIN reaches POS/KDS/stock only") + doc 05 §5 rule 4 (receiving account payments is a first-class POS action). |
| D11 | `branches.building_id` is the single stored branch↔building link | Doc 01 §1 lists the link on both entities; one physical FK avoids circular writes — the reverse direction is a query. |
| D12 | Prices exist only as versioned rows (`item_prices`, `modifier_option_prices`); there is no mutable `base_price` column | Doc 02 §4 versioning + doc 10 §5: a stored current-price column could disagree with its own history. The current price is a resolution, not a field. |
| D13 | Phase 2 capability rows added: `org.manage` (Owner), `fx.set_rate` (Owner+Accountant), `device.manage` (Owner) | Additive matrix change per the doc 16 freeze policy; grants follow the doc 01 §3 role summaries. |
| D14 | The pull cursor is a central `AUTO_INCREMENT` sequence over `sync_changes` | Central-only infrastructure with a single writer; the doc 10 §1 UUID rule targets device-created domain records. |
| D15 | Device tokens are `<device_id>.<secret>` with only `sha256(secret)` stored | A leaked central database must not leak usable sync credentials (doc 10 §7). |
| D16 | Branch-side SQLite keeps soft references where the centre enforces FKs (e.g. `role_assignments.branch_id`) | SQLite cannot `ALTER TABLE … ADD CONSTRAINT`; rows arrive via pull after central validation, and integrity across the sync boundary is enforced at the centre (doc 10 §5). |
| D17 | **The POS ships as a client-installed PWA, not a branch-PC service** (owner, 2026-08-05) | We have no hardware scope: the client installs the PWA wherever and whenever they want. Supersedes the doc 12 §3–§4 branch stack (Windows service, NSSM/WinSW, branch SQLite, bundled installer, ESC/POS) and the doc 17 §2 hardware protocol; the centre stack is unchanged. Offline trading is delivered in-app: service worker + IndexedDB, sale-path domain logic in JS, zero network calls on the sale path. Accepted trade-offs and their mitigations: (a) sale-path logic exists in JS as well as PHP — held identical by PHP-generated parity vectors asserted against the JS kernel in CI (D20); (b) local durability is IndexedDB with `navigator.storage.persist()`, bounded by Phase 4 push draining the outbox when online; (c) at-rest encryption of device data inherits the device OS (doc 10 §7 note). |
| D18 | PINs additionally hashed as PBKDF2-SHA256 in `users.pin_pbkdf2`, synced down with `pin_hash` | Offline PIN login in a PWA needs WebCrypto-verifiable hashes; bcrypt cannot be verified in-browser without a third-party lib. Additive kernel change per the doc 16 freeze policy: `Auth::setPin` writes both columns; the pull `user` entity carries both (`password_hash` still stripped). |
| D19 | POS frontend is dependency-free vanilla ES modules | Doc 12 §6 offered Preact+htm or Alpine.js; a ~40-line hand-written DOM helper covers the POS's needs, keeps the no-build rule, and puts zero third-party code on the sale path. |
| D20 | JS↔PHP parity via generated test vectors | `tools/generate-pwa-vectors.php` exports money/rounding/FX/business-day/capability cases from the PHP kernel into `var/pwa-vectors/`; `tests/Pwa/run.mjs` (Node ≥ 20) asserts the JS kernel reproduces them exactly. Runs beside PHPUnit in CI. |
| D21 | 86 set from the POS is local-first | The selling device is the only enforcement point (single terminal, doc 10 §9); the flag applies locally at once and reaches the centre with Phase 4 push. A central pull upsert still wins at apply time. |
| D22 | Shift variance tolerance is per-branch config with shipped defaults | Doc 06 §3: `branches.variance_tolerance_iqd` (default 1,000) and `variance_hard_threshold_iqd` (default 10,000), added in central 0003, flowing down the existing `branch` pull entity. Back-office edit UI when a branch first needs a different value. |
| D23 | Receipts print through the browser/OS print dialog (80 mm print stylesheet) | Hardware-agnostic per D17; printing stays optional per branch (doc 04 §7) and the record exists regardless. `tools/hardware/arabic-print-test.php` remains as a legacy aid for clients who choose an ESC/POS printer; nothing in `src/` depends on it. |
