# 16 — Kernel API Reference

The shared kernel of [12 §7](12-stack-and-platform.md), built first so 31 developers do
not invent 31 frameworks. **Status: FROZEN for Phase 1 exit.**

Freeze policy: feature streams build **on** the kernel, never **in** it. After freeze,
breaking a public signature listed here requires kernel-stream review plus an entry in
the [14 — Conventions](14-conventions.md) decision log; additive changes (new method, new
optional parameter, new capability row) require kernel-stream review only. The suite in
`tests/Kernel/` is the executable form of this contract.

All classes are `final`, namespaced under `Cafe\Kernel\`, and covered by the
dual-dialect test suite where they touch SQL.

---

## Db\Db — the data access layer

```php
Db::sqlite(string $path): Db                 // WAL + foreign_keys ON (doc 12 §4)
Db::mysql(host, dbname, user, password, port = 3306): Db   // utf8mb4 (doc 12 §5)

$db->driver(): 'sqlite'|'mysql'
$db->select(string $sql, array $params = []): array        // list of assoc rows
$db->selectOne(...): ?array
$db->selectValue(...): mixed                               // first column or null
$db->execute(string $sql, array $params = []): int         // affected rows
$db->insert(string $table, array $row): void
$db->update(string $table, array $set, string $where, array $params = []): int
$db->transaction(callable $fn): mixed        // commit on return, rollback on throw;
                                             // nested calls join the outer transaction
$db->inTransaction(): bool
$db->pdo(): PDO                              // infrastructure escape hatch ONLY
```

Invariants: prepared statements everywhere; exceptions on error; identifiers validated
(`[A-Za-z_][A-Za-z0-9_]*`) and quoted per dialect. Feature code never calls `pdo()`.

## Db\Migrator — the migration runner

```php
new Migrator(Db $db, string $dir)
$m->discover(): list<{version, name, file}>   // resolves .mysql.sql/.sqlite.sql variants
$m->pending(): list<...>                      // throws on out-of-order versions
$m->apply(): list<{version, name}>            // records in `migrations` table
```

Files: `NNNN_name[.dialect].sql` in `migrations/central` or `migrations/branch`. SQLite
migrations run in a transaction; MySQL DDL auto-commits (repair forward, never edit).

## Money\Money · Money\CashRounding · Money\Fx

```php
Money::CURRENCIES                 // ['IQD' => 0, 'USD' => 2] — THE scale authority
Money::of(int $minor, string $currency): Money    // ->amount, ->currency readonly
Money::parse('12.50', 'USD'): Money               // rejects excess precision
Money::zero($c) · Money::scale($c)
->add/subtract(Money): Money      // CurrencyMismatchException across currencies
->multiply(int) · ->multiplyRatio(int $num, int $den)  // half away from zero
->negate/abs/isZero/isNegative/isPositive/equals/compareTo
->toDecimalString(): '12.50' · ->format(): '6,750 IQD'  // Western digits (doc 10 §6)

CashRounding::round(Money, int $increment = 250): ['rounded' => Money, 'difference' => Money]
    // NEAREST increment, half away from zero; difference = rounded − original;
    // apply to the CASH TENDER LEG ONLY and record the difference (doc 10 §2)

Fx::convert(Money $from, string $rate, string $toCurrency): Money  // integer math only
Fx::parseRate('1310.25'): [131025, 2]
    // caller snapshots the rate string onto the transaction (doc 10 §2)
```

## Support\Uuid · Support\BusinessDay

```php
Uuid::v4(): string · Uuid::isValid(string): bool      // device-generated PKs (doc 10 §1)

BusinessDay::of(DateTimeImmutable $instant, string $tz = 'Asia/Baghdad',
                string $cutoff = '04:00'): 'YYYY-MM-DD'   // doc 10 §3
BusinessDay::localTime($instant, $tz): 'Y-m-d H:i:s'
```

## Validation\Validator

```php
Validator::make(array $data, array $rules): Validator
$v->passes()/fails() · $v->errors(): [field => [codes]] · $v->validated(): array

Rules: required nullable string int bool array min:max: len: in:a,b uuid date datetime
       email phone_e164 digits_between:a,b currency
```

Error codes are machine-stable; the UI translates (Arabic-first). Unknown rules throw.
`validated()` returns only rule-covered, present fields.

## Http\Router · Http\Request · Http\Response

```php
$router->get/post/put/delete(string $pattern, callable $handler)   // '{param}' segments
$router->dispatch($method, $path): [callable, array $params]
    // NotFoundException | MethodNotAllowedException($allowed)

Request::fromGlobals(): Request        // JSON bodies parsed; invalid JSON → BadRequestException
$r->method/path/ip() · $r->query(?key, default) · $r->input(?key, default)
$r->header(name)  // case-insensitive   · $r->bearerToken(): ?string

Response::json($data, $status = 200) · ::error($code, $message, $status, $fields = [])
Response::text(...) · ::html(...) · ::redirect($location, $status = 302)   // html/redirect added Phase 2
Response::noContent() · ->withHeader(...): Response (immutable)
->status/headers/body/jsonBody() · ->send()
    // error envelope: {"error": {"code", "message", "fields?"}}
```

## Auth\Auth — authentication + user records (doc 01 §2)

```php
new Auth(Db $db, SessionStore $session, int $idleLockSeconds = 60, string $timezone = 'Asia/Baghdad')

$auth->loginWithPassword(string $phoneOrEmail, string $password, ?now): array   // back office
$auth->loginWithPin(string $deviceBranchId, string $pin, ?now): array
    // branch id comes from DEVICE CONFIG, never user input; only users with an
    // active assignment covering that branch match; hashes never leave the class
$auth->logout()
$auth->currentUserId(?now): ?string    // null when unauthenticated OR idle-locked
$auth->authMode(): ?'pin'|'password' · $auth->deviceBranchId(): ?string
$auth->isIdleLocked(?now): bool · $auth->touch(?now)       // 60 s default (doc 01 §2)

$auth->createUser(array $fields, ?now): string   // validates doc 01 §2 fields
$auth->setPin(string $userId, string $pin, ?now)
    // 4–6 digits; DuplicatePinException when another user in an intersecting
    // branch scope holds the same PIN (unique within a branch, doc 01 §2).
    // Phase 3 (additive, D18): also writes users.pin_pbkdf2 for offline
    // WebCrypto verification in the PWA.
Auth::pbkdf2Pin(string $pin, ?string $salt = null): string   // added Phase 3 (D18)
Auth::PIN_PBKDF2_ITERATIONS                                   // 100000
$auth->setPassword(string $userId, string $password, ?now)   // ≥ 8 chars, hashed
$auth->setUserStatus(string $userId, 'active'|'suspended'|'terminated', ?now)
    // LastOwnerException guard (doc 01 §3 rule 3)
```

`SessionStore` interface with `NativeSessionStore` (PHP session) and `ArraySessionStore`
(tests). Session id regenerates on every login.

## Auth\Rbac · Auth\Capabilities · Auth\Decision · Auth\BranchScope (doc 01 §3)

```php
Capabilities::MATRIX          // exact doc 01 §3 matrix: capability => role => mode
Capabilities::GRANTED|REQUEST|LIMITED · ::ROLES · ::PIN_REACHABLE
Capabilities::grant(role, capability): ?mode · ::exists(cap) · ::isPinReachable(cap)

new Rbac(Db $db, string $timezone = 'Asia/Baghdad')
Rbac::ALL_BRANCHES            // assignment scope
Rbac::ORG                     // check scope for org-level actions (needs ALL_BRANCHES)

$rbac->check(userId, capability, branchScope, authMode = 'password', ?now): Decision
    // branchScope REQUIRED (branch id or Rbac::ORG) — missing scope is an error,
    // never "all"; PIN sessions reach only PIN_REACHABLE capabilities
$rbac->assertCan(...): Decision      // passes GRANTED|LIMITED, throws on REQUEST/deny
$rbac->permittedBranchScope(userId, ?now): BranchScope   // {allBranches, branchIds[]}
$rbac->assign(userId, role, branchId|Rbac::ALL_BRANCHES, ?validFrom, ?validTo, ?now): id
$rbac->revoke(assignmentId, ?now)    // soft (revoked_at_utc); LastOwnerException guard
Rbac::isLastActiveOwner(Db, userId, tz?, ?now): bool
Rbac::assertDifferentApprover(actorId, approverId)       // SelfApprovalException
```

`Decision`: readonly `{allowed, mode, role, reason}`. REQUEST means *may initiate,
takes effect only after approval by a higher role* — captured as `approved_by_user_id`.
LIMITED means *granted subject to the owning module's configured limit* (cashier discount
cap, manager PO threshold, manager user.manage restricted to own branch + non-financial
roles).

## Audit\Audit (doc 01 §4)

```php
new Audit(Db $db, string $orgId)
$audit->write(array $event): string   // returns event id; APPEND-ONLY by construction
```

Required: `actor_user_id, entity_type, entity_id, action, source(pos|back_office|sync|system)`.
Optional: `branch_id, device_id, shift_id, approved_by_user_id, before[], after[],
reason_code, note, occurred_at_utc, timezone, cutoff, occurred_at_local, business_day`.
Local time and business day derive from the UTC instant unless given. Self-approval
throws (and a schema CHECK backs it). Call inside the mutation's transaction.

## Auth\Csrf *(added Phase 2)*

```php
Csrf::token(SessionStore): string        // get-or-create the session token
Csrf::validate(SessionStore, mixed $presented): bool   // constant-time compare
```

Every state-changing back-office form carries the token as `_csrf`.

## Exceptions (`Cafe\Kernel\Exception\`)

`KernelException` (base) — `AccessDeniedException`, `SelfApprovalException`,
`LastOwnerException`, `DuplicatePinException`, `CurrencyMismatchException`,
`NotFoundException`, `MethodNotAllowedException($allowed)`, `BadRequestException`.

---

## Kernel schema (migration 0001, both dialects)

`users` (doc 01 §2), `role_assignments` (doc 01 §3; `revoked_at_utc` soft revoke),
`audit_events` (doc 01 §4; CHECKs on source and self-approval), plus the runner's
`migrations` table. `branches`/`organization` and the `branch_id` FKs are Phase 2
(decision D6).
